diff --git a/lib/models/ObjectMD.ts b/lib/models/ObjectMD.ts index 1397ddef5..5cfebae39 100644 --- a/lib/models/ObjectMD.ts +++ b/lib/models/ObjectMD.ts @@ -23,17 +23,24 @@ export type Backend = { site: string; status: string; dataStoreVersionId: string; + destination?: string; + role?: string; }; export type ReplicationInfo = { status: string; backends: Backend[]; content: string[]; - destination: string; - storageClass: string; - role: string; - storageType: string; - dataStoreVersionId: string; + /** @deprecated in favor of per-backend destination for multi-destination CRR. */ + destination?: string; + /** @deprecated in favor of per-backend storageClass for multi-destination CRR. */ + storageClass?: string; + /** @deprecated in favor of per-backend role for multi-destination CRR. */ + role?: string; + /** @deprecated in favor of per-backend storageType for multi-destination CRR. */ + storageType?: string; + /** @deprecated in favor of per-backend dataStoreVersionId for multi-destination. */ + dataStoreVersionId?: string; isNFS?: boolean; }; @@ -1175,6 +1182,7 @@ export default class ObjectMD { return undefined; } + /** @deprecated in favor of per-backend dataStoreVersionId for multi-destination CRR. */ setReplicationDataStoreVersionId(versionId: string) { this._data.replicationInfo.dataStoreVersionId = versionId; return this; @@ -1205,26 +1213,31 @@ export default class ObjectMD { return this; } + /** @deprecated in favor of per-backend storageType for multi-destination CRR. */ setReplicationStorageType(storageType: string) { this._data.replicationInfo.storageType = storageType; return this; } + /** @deprecated in favor of per-backend storageClass for multi-destination CRR. */ setReplicationStorageClass(storageClass: string) { this._data.replicationInfo.storageClass = storageClass; return this; } + /** @deprecated in favor of per-backend destination for multi-destination CRR. */ setReplicationTargetBucket(destination: string) { this._data.replicationInfo.destination = destination; return this; } + /** @deprecated in favor of per-backend role for multi-destination CRR. */ setReplicationRoles(role: string) { this._data.replicationInfo.role = role; return this; } + /** @deprecated in favor of per-backend dataStoreVersionId for multi-destination. */ getReplicationDataStoreVersionId() { return this._data.replicationInfo.dataStoreVersionId; } @@ -1241,20 +1254,24 @@ export default class ObjectMD { return this._data.replicationInfo.content; } + /** @deprecated in favor of per-backend role for multi-destination CRR. */ getReplicationRoles() { return this._data.replicationInfo.role; } + /** @deprecated in favor of per-backend storageType for multi-destination CRR. */ getReplicationStorageType() { return this._data.replicationInfo.storageType; } + /** @deprecated in favor of per-backend storageClass for multi-destination CRR. */ getReplicationStorageClass() { return this._data.replicationInfo.storageClass; } + /** @deprecated in favor of per-backend destination for multi-destination CRR. */ getReplicationTargetBucket() { - const destBucketArn = this._data.replicationInfo.destination; + const destBucketArn = this._data.replicationInfo.destination ?? ''; return destBucketArn.split(':').slice(-1)[0]; } diff --git a/lib/models/ReplicationConfiguration.ts b/lib/models/ReplicationConfiguration.ts index fc0ba27cc..ae39940ab 100644 --- a/lib/models/ReplicationConfiguration.ts +++ b/lib/models/ReplicationConfiguration.ts @@ -13,7 +13,7 @@ const RULE_ID_LIMIT = 255; const validStorageClasses = ['STANDARD', 'STANDARD_IA', 'REDUCED_REDUNDANCY']; /** - Example XML request: + Example V1 XML request: IAM-role-ARN @@ -28,22 +28,46 @@ const validStorageClasses = ['STANDARD', 'STANDARD_IA', 'REDUCED_REDUNDANCY']; + + + Example V2 XML request: + + + arn:aws:iam::123:role/src,arn:aws:iam::111:role/dst - Rule-2 - ... + Rule-1 + rule-status + 1 + + key-prefix + + + arn:aws:s3:::bucket-name + dest-site + 222222222222 + - ... */ +export type ReplicationFormat = 'v1' | 'v2'; + export type Rule = { prefix: string; enabled: boolean; id: string; - storageClass?: any; + storageClass?: string; + priority?: number; + destination?: string; + account?: string; +}; + +export type Destination = { + StorageClass?: string[]; + Bucket?: string[]; + Account?: string[]; }; -export type Destination = { StorageClass: string[]; Bucket: string }; export type XMLRule = { Prefix?: string[]; Status: Status[]; @@ -51,21 +75,25 @@ export type XMLRule = { Destination: Destination[]; Transition?: any[]; NoncurrentVersionTransition?: any[]; - Filter?: string; + Filter?: any[]; + Priority?: string[]; }; export type ReplicationConfigurationMetadata = { role: string, - destination: string, + /** + * @deprecated in favor of per-rule destination field + */ + destination?: string, rules: Rule[], preferredReadLocation?: string | null, + format?: ReplicationFormat, }; export default class ReplicationConfiguration { _parsedXML: any; _log: RequestLogger; _config: any; - _configPrefixes: string[]; _configIDs: string[]; _role: string | null; _destination: string | null; @@ -73,6 +101,7 @@ export default class ReplicationConfiguration { _prevStorageClass: null; _hasScalityDestination: boolean | null; _preferredReadLocation: string | null; + _format: ReplicationFormat | null; /** * Create a ReplicationConfiguration instance @@ -85,17 +114,14 @@ export default class ReplicationConfiguration { this._parsedXML = xml; this._log = log; this._config = config; - this._configPrefixes = []; this._configIDs = []; - // The bucket metadata model of replication config. Note there is a - // single `destination` property because we can replicate to only one - // other bucket. Thus each rule is simplified to these properties. this._role = null; this._destination = null; this._rules = null; this._prevStorageClass = null; this._hasScalityDestination = null; this._preferredReadLocation = null; + this._format = null; } /** @@ -124,16 +150,21 @@ export default class ReplicationConfiguration { /** * The preferred read location - * @return {string|null} - The preferred read location if defined, - * otherwise null * * FIXME ideally we should be able to specify one preferred read * location for each rule */ - getPreferredReadLocation() { + getPreferredReadLocation(): string | null { return this._preferredReadLocation; } + /** + * The replication configuration format ('v1' or 'v2'), as submitted. + */ + getFormat(): ReplicationFormat { + return this._format ?? 'v2'; + } + /** * Get the replication configuration * @return - The replication configuration @@ -144,6 +175,7 @@ export default class ReplicationConfiguration { destination: this.getDestination(), rules: this.getRules(), preferredReadLocation: this.getPreferredReadLocation(), + format: this.getFormat(), }; } @@ -153,25 +185,78 @@ export default class ReplicationConfiguration { * @return - The rule object to push into the `Rules` array */ _buildRuleObject(rule: XMLRule) { - const base = { + const obj: Rule = { id: '', - prefix: rule.Prefix?.[0] ?? '', + prefix: this._extractPrefix(rule), enabled: rule.Status[0] === 'Enabled', }; - const obj: Rule = { ...base }; + // ID is an optional property, but create one if not provided or is ''. // We generate a 48-character alphanumeric, unique ID for the rule. obj.id = rule.ID && rule.ID[0] !== '' ? rule.ID[0] : Buffer.from(uuid()).toString('base64'); + // StorageClass is an optional property. - if (rule.Destination[0].StorageClass) { - obj.storageClass = rule.Destination[0].StorageClass[0]; + const storageClass = rule.Destination[0].StorageClass?.[0]; + if (storageClass) { + obj.storageClass = storageClass; + } + + if (rule.Priority?.[0] !== undefined) { + obj.priority = +rule.Priority[0]; } + + const bucket = rule.Destination[0].Bucket?.[0]; + if (bucket) { + obj.destination = bucket; + } + + const account = rule.Destination[0].Account?.[0]; + if (account) { + obj.account = account; + } + return obj; } + /** + * Resolve the destination role ARN for a rule. The top-level role + * accepts either a single ARN or a comma-separated `source,destination` + * pair; the destination side is used when present. If the rule carries + * an `account` override, its 12-digit ID replaces the account segment. + */ + static resolveDestinationRole(topRole: string, account?: string): string | undefined { + if (!topRole) { + return undefined; + } + const roles = topRole.split(','); + const destRole = roles[1] ?? roles[0]; + if (!account) { + return destRole; + } + const arnParts = destRole.split(':'); + arnParts[4] = account; + return arnParts.join(':'); + } + + /** + * Extract the prefix from a rule. If the rule carries a element + * we read , treating an empty or self-closing + * as a match-all (empty prefix); otherwise we read the top-level . + */ + _extractPrefix(rule: XMLRule): string { + if (Array.isArray(rule.Filter)) { + const filter = rule.Filter[0]; + if (!filter || typeof filter === 'string') { + return ''; + } + return filter.Prefix?.[0] ?? ''; + } + return rule.Prefix?.[0] ?? ''; + } + /** * Check if the Role field of the replication configuration is valid * @param ARN - The Role field value provided in the configuration @@ -202,23 +287,24 @@ export default class ReplicationConfiguration { } const role: string = parsedRole[0]; const rolesArr = role.split(','); - if (this._hasScalityDestination && rolesArr.length !== 2) { - return errorInstances.InvalidArgument.customizeDescription( - 'Invalid Role specified in replication configuration: ' + - 'Role must be a comma-separated list of two IAM roles' - ); + const invalidRoleError = (reason: string) => + errorInstances.InvalidArgument.customizeDescription( + `Invalid Role specified in replication configuration: ${reason}`); + + // Role accepts either a single ARN (used as template for both + // source and destination) or two comma-separated ARNs (source, + // destination) when role names differ between sides. + if (rolesArr.length > 2) { + return invalidRoleError( + 'Role must be a single ARN or a comma-separated pair'); } if (!this._hasScalityDestination && rolesArr.length > 1) { - return errorInstances.InvalidArgument.customizeDescription( - 'Invalid Role specified in replication configuration: ' + - 'Role may not contain a comma separator' - ); + return invalidRoleError( + 'Role may not contain a comma separator'); } const invalidRole = rolesArr.find(r => !this._isValidRoleARN(r)); if (invalidRole !== undefined) { - return errorInstances.InvalidArgument.customizeDescription( - `Invalid Role specified in replication configuration: '${invalidRole}'` - ); + return invalidRoleError(`'${invalidRole}'`); } this._role = role; return undefined; @@ -255,6 +341,7 @@ export default class ReplicationConfiguration { for (let i = 0; i < rules.length; i++) { const err = this._parseStatus(rules[i]) || + this._parsePriority(rules[i]) || this._parsePrefix(rules[i]) || this._parseID(rules[i]) || this._parseDestination(rules[i]); @@ -265,32 +352,54 @@ export default class ReplicationConfiguration { rulesArr.push(this._buildRuleObject(rules[i])); } + + const hasAnyPriority = rulesArr.some(r => r.priority !== undefined); + if (!hasAnyPriority) { + const sortedRules = [...rulesArr] + .sort((a, b) => (a.prefix < b.prefix ? -1 : a.prefix > b.prefix ? 1 : 0)); + for (let i = 0; i < sortedRules.length - 1; i++) { + const current = sortedRules[i].prefix; + const next = sortedRules[i + 1].prefix; + + if (next.startsWith(current)) { + return errorInstances.InvalidRequest.customizeDescription( + `Found overlapping prefixes '${current}' and '${next}'` + ); + } + } + } + this._rules = rulesArr; return undefined; } /** - * Check that the `Status` property is valid - * @param rule - The rule object from this._parsedXML + * Parse and validate the rule prefix. v1 and v2 differ only in where + * the prefix lives: top-level for v1, for + * v2. The first rule that actually carries a prefix pins the format + * for the whole configuration; subsequent rules must match. */ - _parseStatus(rule: XMLRule) { - const status = rule.Status && rule.Status[0]; - if (!status || !['Enabled', 'Disabled'].includes(status)) { + _parsePrefix(rule: XMLRule) { + const hasFilter = Array.isArray(rule.Filter); + const hasTopPrefix = Array.isArray(rule.Prefix); + if (hasFilter && hasTopPrefix) { return errors.MalformedXML; } - return undefined; - } - /** - * Check that the `Prefix` property is valid - * @param rule - The rule object from this._parsedXML - */ - _parsePrefix(rule: XMLRule) { - if (Array.isArray(rule.Prefix) && rule.Prefix.length > 1) { + if (hasFilter || hasTopPrefix) { + const ruleFormat: ReplicationFormat = hasFilter ? 'v2' : 'v1'; + if (this._format === null) { + this._format = ruleFormat; + } else if (this._format !== ruleFormat) { + return errors.MalformedXML; + } + } + + if (hasTopPrefix && rule.Prefix!.length > 1) { return errors.MalformedXML; } - const prefix = rule.Prefix?.[0] ?? ''; + const prefix = this._extractPrefix(rule); if (typeof prefix !== 'string') { return errors.MalformedXML; @@ -301,20 +410,38 @@ export default class ReplicationConfiguration { 'Rule prefix cannot be longer than maximum allowed key length of 1024' ); } - - // Each Prefix in a list of rules must not overlap. For example, two - // prefixes 'TaxDocs' and 'TaxDocs/2015' are overlapping. An empty - // string prefix is expected to overlap with any other prefix. - for (let i = 0; i < this._configPrefixes.length; i++) { - const used = this._configPrefixes[i]; - if (prefix.startsWith(used) || used.startsWith(prefix)) { - return errorInstances.InvalidRequest.customizeDescription( - `Found overlapping prefixes '${used}' and '${prefix}'` - ); - } + + return undefined; + } + + /** + * Check that the `Status` property is valid + * @param rule - The rule object from this._parsedXML + */ + _parseStatus(rule: XMLRule) { + const status = rule.Status && rule.Status[0]; + if (!status || !['Enabled', 'Disabled'].includes(status)) { + return errors.MalformedXML; } + return undefined; + } - this._configPrefixes.push(prefix); + /** + * Parse and validate the Priority value. Priority is optional in + * both v1 and v2 and must be a non-negative integer when present. + * Cross-rule semantics (same-destination disambiguation) are + * resolved by the replication runtime. + */ + _parsePriority(rule: XMLRule) { + if (!rule.Priority || !rule.Priority[0]) { + return undefined; + } + const priority = +rule.Priority[0]; + if (!Number.isInteger(priority) || priority < 0) { + return errorInstances.InvalidArgument.customizeDescription( + 'Priority must be a non-negative integer' + ); + } return undefined; } @@ -375,7 +502,7 @@ export default class ReplicationConfiguration { (endpoint: any) => endpoint.site === storageClass ); if (endpoint) { - // We do not support replication to cold location. + // We do not support replication to cold location. // Only transition to cold location is supported. if (endpoint.site && this._config.locationConstraints[endpoint.site]?.isCold) { return false; @@ -435,13 +562,28 @@ export default class ReplicationConfiguration { 'The specified bucket is not valid' ); } - // We can replicate objects only to one destination bucket. - if (this._destination && this._destination !== bucketARN) { - return errorInstances.InvalidRequest.customizeDescription( - 'The destination bucket must be the same for all rules' + + return undefined; + } + + /** + * Check that the `Account` property is valid. Account is optional + * in both v1 and v2; when present it must be a 12-digit numeric ID + * and is later used to derive a per-rule destination role. + * @param destination - The destination object from this._parsedXML + */ + _parseAccount(destination: Destination) { + if (!destination.Account) { + return undefined; + } + + const account = destination.Account[0]; + if (typeof account !== 'string' || !/^[0-9]{12}$/.test(account)) { + return errorInstances.InvalidArgument.customizeDescription( + 'Account must be a 12-digit numeric account ID' ); } - this._destination = bucketARN; + return undefined; } @@ -454,7 +596,10 @@ export default class ReplicationConfiguration { if (!dest) { return errors.MalformedXML; } - const err = this._parseStorageClass(dest) || this._parseBucket(dest); + const err = + this._parseStorageClass(dest) || + this._parseBucket(dest) || + this._parseAccount(dest); if (err) { return err; } @@ -474,44 +619,52 @@ export default class ReplicationConfiguration { return errors.InvalidRequest.customizeDescription( 'No configured replication endpoint'); } - return this._parseRole(); + const roleErr = this._parseRole(); + if (roleErr) { + return roleErr; + } + return undefined; } /** - * Get the XML representation of the configuration object + * Get the XML representation of the configuration object. + * v1 and v2 differ only in where the prefix lives (top-level + * vs ). Account is emitted whenever set, regardless + * of format. Legacy stored metadata with no `format` field falls + * back to v1 to preserve the original wire shape. * @param config - The bucket replication configuration * @return - The XML representation of the configuration */ static getConfigXML(config: ReplicationConfigurationMetadata) { - const { role, destination, rules } = config; - const Role = `${escapeForXml(role)}`; - const Bucket = `${escapeForXml(destination)}`; - const rulesXML = rules - .map(rule => { - const { prefix, enabled, storageClass, id } = rule; - const Prefix = - prefix === '' - ? '' - : `${escapeForXml(prefix)}`; - const Status = `${ - enabled ? 'Enabled' : 'Disabled' - }`; - const StorageClass = storageClass - ? `${storageClass}` - : ''; - const Destination = `${Bucket}${StorageClass}`; - // If the ID property was omitted in the configuration object, we - // create an ID for the rule. Hence it is always defined. - const ID = `${escapeForXml(id)}`; - return `${ID}${Prefix}${Status}${Destination}`; - }) - .join(''); + const { role, destination, rules, format } = config; + const isV2 = (format ?? 'v1') === 'v2'; + + const rulesXML = rules.map(rule => { + const { prefix, enabled, storageClass, id, priority, destination: ruleDest, account } = rule; + + const ID = `${escapeForXml(id)}`; + const Status = `${enabled ? 'Enabled' : 'Disabled'}`; + + const prefixContent = prefix === '' ? '' : `${escapeForXml(prefix)}`; + const prefixXML = isV2 ? `${prefixContent}` : prefixContent; + const priorityXML = priority !== undefined ? `${priority}` : ''; + + const targetBucket = ruleDest || destination || ''; + const Bucket = `${escapeForXml(targetBucket)}`; + const StorageClass = storageClass ? `${storageClass}` : ''; + const AccountXML = account ? `${escapeForXml(account)}` : ''; + + const Destination = `${Bucket}${StorageClass}${AccountXML}`; + + return `${ID}${priorityXML}${prefixXML}${Status}${Destination}`; + }).join(''); + return ( - '' + - '' + - `${rulesXML}${Role}` + - '' + `` + + `` + + `${rulesXML}` + + `${escapeForXml(role)}` + + `` ); } @@ -522,19 +675,33 @@ export default class ReplicationConfiguration { */ static validateConfig(config: ReplicationConfigurationMetadata) { assert.strictEqual(typeof config, 'object'); - const { role, rules, destination } = config; + const { role, rules, destination, format } = config; assert.strictEqual(typeof role, 'string'); - assert.strictEqual(typeof destination, 'string'); + if (destination !== undefined) { + assert.strictEqual(typeof destination, 'string'); + } + if (format !== undefined) { + assert(format === 'v1' || format === 'v2'); + } assert.strictEqual(Array.isArray(rules), true); rules.forEach(rule => { assert.strictEqual(typeof rule, 'object'); - const { prefix, enabled, id, storageClass } = rule; + const { prefix, enabled, id, storageClass, priority, destination, account } = rule; assert.strictEqual(typeof prefix, 'string'); assert.strictEqual(typeof enabled, 'boolean'); assert(id === undefined || typeof id === 'string'); if (storageClass !== undefined) { assert.strictEqual(typeof storageClass, 'string'); } + if (priority !== undefined) { + assert.strictEqual(typeof priority, 'number'); + } + if (destination !== undefined) { + assert.strictEqual(typeof destination, 'string'); + } + if (account !== undefined) { + assert.strictEqual(typeof account, 'string'); + } }); } } diff --git a/tests/unit/models/ReplicationConfiguration.spec.ts b/tests/unit/models/ReplicationConfiguration.spec.ts index 0dafeda54..b96baa966 100644 --- a/tests/unit/models/ReplicationConfiguration.spec.ts +++ b/tests/unit/models/ReplicationConfiguration.spec.ts @@ -68,12 +68,12 @@ function getPreferredReadXMLConfig(hasPreferredRead) { } describe('ReplicationConfiguration.parseConfiguration()', () => { - // --- Valid Configurations --- - describe('Prefix validation', () => { + describe('V1 Prefix validation', () => { it('should succeed for a valid configuration without a prefix', () => { const repConfig = { Role: [TEST_ROLE], Rule: [{ + Prefix: [''], Status: ['Enabled'], Destination: [{ Bucket: ['arn:aws:s3:::crr-dest'], @@ -85,12 +85,15 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { const result = instance.parseConfiguration(); expect(result).toBeUndefined(); expect(instance.getRole()).toEqual(TEST_ROLE); - expect(instance.getDestination()).toEqual('arn:aws:s3:::crr-dest'); + expect(instance.getDestination()).toBeNull(); const rules = instance.getRules(); expect(rules.length).toEqual(1); expect(rules[0].enabled).toBe(true); expect(typeof rules[0].id).toBe('string'); expect(rules[0].prefix).toEqual(''); + expect(rules[0].destination).toEqual('arn:aws:s3:::crr-dest'); + expect(rules[0].account).toBeUndefined(); + expect(instance.getFormat()).toEqual('v1'); }); it('should succeed for multiple valid rules with non-overlapping prefixes', () => { @@ -116,17 +119,19 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { const result = instance.parseConfiguration(); expect(result).toBeUndefined(); expect(instance.getRole()).toEqual(TEST_ROLE); - expect(instance.getDestination()).toEqual('arn:aws:s3:::crr-dest'); + expect(instance.getDestination()).toBeNull(); expect(instance.getRules()).toEqual([ { enabled: true, id: 'ImagesRule', prefix: 'main/images/', + destination: 'arn:aws:s3:::crr-dest', }, { enabled: false, id: 'DocsAndProjects', prefix: 'main/documents/', + destination: 'arn:aws:s3:::crr-dest', }, ]); }); @@ -205,6 +210,34 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { const result = instance.parseConfiguration(); expect(result).toEqual(errors.MalformedXML); }); + + it('should accept V1 rules targeting different destination buckets', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Prefix: ['a/'], + Status: ['Enabled'], + Destination: [{ Bucket: ['arn:aws:s3:::bucket-a'] }], + }, + { + ID: ['rule2'], + Prefix: ['b/'], + Status: ['Enabled'], + Destination: [{ Bucket: ['arn:aws:s3:::bucket-b'] }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].destination).toEqual('arn:aws:s3:::bucket-a'); + expect(rules[1].destination).toEqual('arn:aws:s3:::bucket-b'); + expect(instance.getFormat()).toEqual('v1'); + }); }); it('should succeed for a minimal valid configuration without storage class', () => { @@ -223,13 +256,14 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { const result = instance.parseConfiguration(); expect(result).toBeUndefined(); expect(instance.getRole()).toEqual(TEST_ROLE); - expect(instance.getDestination()).toEqual('arn:aws:s3:::crr-dest'); + expect(instance.getDestination()).toBeNull(); const rules = instance.getRules(); expect(rules.length).toEqual(1); expect(rules[0].enabled).toBe(true); // should have generated a new random ID expect(typeof rules[0].id).toBe('string'); expect(rules[0].prefix).toEqual(''); + expect(rules[0].destination).toEqual('arn:aws:s3:::crr-dest'); }); it('should succeed for a minimal valid configuration including Rule ID and destination StorageClass', () => { @@ -255,6 +289,7 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { id: 'RuleID', prefix: '', storageClass: 'STANDARD', + destination: 'arn:aws:s3:::crr-dest', }, ]); }); @@ -277,7 +312,7 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { expect(result).toEqual(errors.MalformedXML); }); - it('should return InvalidArgument when Scality destination has a single role', () => { + it('should accept a single-ARN Role for V1 Scality destination', () => { const repConfig = { Role: ['arn:aws:iam::942839175607:role/crr-trust-role'], Rule: [{ @@ -291,7 +326,7 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { const instance = new ReplicationConfiguration({ ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); const result = instance.parseConfiguration(); - expect(result).toEqual(errors.InvalidArgument); + expect(result).toBeUndefined(); }); // eslint-disable-next-line max-len @@ -312,6 +347,24 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { expect(result).toEqual(errors.InvalidArgument); }); + it('should return InvalidArgument when non-Scality destination has multiple roles', () => { + const repConfig = { + Role: ['arn:aws:iam::942839175607:role/role-a,arn:aws:iam::989181102323:role/role-b'], + Rule: [{ + Prefix: [''], + Status: ['Enabled'], + Destination: [{ + Bucket: ['arn:aws:s3:::crr-dest'], + StorageClass: ['awsbackend'], + }], + }], + }; + const instance = new ReplicationConfiguration({ ReplicationConfiguration: repConfig }, + null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toEqual(errors.InvalidArgument); + }); + it('should return MalformedXML when config has an empty Rule array', () => { const repConfig = { Role: [TEST_ROLE], @@ -507,4 +560,664 @@ describe('ReplicationConfiguration.parseConfiguration()', () => { done(); }); }); + + describe('V2 Prefix validation', () => { + it('should succeed for a valid V2 configuration with Filter', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: ['docs/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::crr-dest'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules(); + expect(rules.length).toEqual(1); + expect(rules[0].prefix).toEqual('docs/'); + expect(rules[0].destination).toEqual('arn:aws:s3:::crr-dest'); + }); + + it('should succeed with empty Filter (matches all objects)', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [''], + Destination: [{ + Bucket: ['arn:aws:s3:::crr-dest'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + expect(instance.getRules()![0].prefix).toEqual(''); + }); + + it('should reject overlapping prefixes in V2 format', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: ['docs'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }, + { + ID: ['rule2'], + Status: ['Enabled'], + Filter: [{ Prefix: ['docs/2024'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-b'], + StorageClass: ['ring'], + }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toEqual(errors.InvalidRequest); + }); + + it('should allow different destination buckets in V2 format', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: ['images/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }, + { + ID: ['rule2'], + Status: ['Enabled'], + Filter: [{ Prefix: ['docs/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-b'], + StorageClass: ['ring'], + }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].destination).toEqual('arn:aws:s3:::bucket-a'); + expect(rules[1].destination).toEqual('arn:aws:s3:::bucket-b'); + }); + + it('should allow overlapping prefixes when all rules have Priority', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Status: ['Enabled'], + Priority: ['1'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }, + { + ID: ['rule2'], + Status: ['Enabled'], + Priority: ['2'], + Filter: [{ Prefix: ['docs/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-b'], + StorageClass: ['ring'], + }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].priority).toEqual(1); + expect(rules[1].priority).toEqual(2); + }); + + it('should accept equal Priority values across rules', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Status: ['Enabled'], + Priority: ['1'], + Filter: [{ Prefix: ['images/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }, + { + ID: ['rule2'], + Status: ['Enabled'], + Priority: ['1'], + Filter: [{ Prefix: ['docs/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-b'], + StorageClass: ['ring'], + }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + }); + + it('should accept Priority on a V1 rule', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Priority: ['1'], + Prefix: ['docs/'], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].priority).toEqual(1); + expect(instance.getFormat()).toEqual('v1'); + }); + + it('should reject a negative Priority', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Priority: ['-1'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toEqual(errors.InvalidArgument); + }); + + it('should accept mixed Priority presence across rules', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [ + { + ID: ['rule1'], + Status: ['Enabled'], + Priority: ['1'], + Filter: [{ Prefix: ['images/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }, + { + ID: ['rule2'], + Status: ['Enabled'], + Filter: [{ Prefix: ['docs/'] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-b'], + StorageClass: ['ring'], + }], + }, + ], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].priority).toEqual(1); + expect(rules[1].priority).toBeUndefined(); + }); + + it('should accept a single-ARN Role for CRR (shared role name)', () => { + const repConfig = { + Role: ['arn:aws:iam::111111111111:role/crr-trust-role'], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + Account: ['222222222222'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].account).toEqual('222222222222'); + }); + + it('should expose v2 format and omit top-level destination in V2', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + expect(instance.parseConfiguration()).toBeUndefined(); + expect(instance.getFormat()).toEqual('v2'); + expect(instance.getDestination()).toBeNull(); + }); + + it('should return InvalidArgument if V2 prefix is longer than 1024 characters', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [new Array(1025).fill('X').join('')] }], + Destination: [{ + Bucket: ['arn:aws:s3:::crr-dest'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toEqual(errors.InvalidArgument); + }); + + it('should detect V2 from Account element (without Filter)', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + Account: ['222222222222'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + expect(instance.getFormat()).toEqual('v2'); + expect(instance.getRules()![0].account).toEqual('222222222222'); + }); + + it('should return InvalidArgument when Account is not a 12-digit ID', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + Account: ['not-a-valid-account'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toEqual(errors.InvalidArgument); + }); + + it('should parse Account into the rule', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + Account: ['222222222222'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].account).toEqual('222222222222'); + }); + + it('should not set per-rule account when Account is not specified', () => { + const repConfig = { + Role: [TEST_ROLE], + Rule: [{ + ID: ['rule1'], + Status: ['Enabled'], + Filter: [{ Prefix: [''] }], + Destination: [{ + Bucket: ['arn:aws:s3:::bucket-a'], + StorageClass: ['ring'], + }], + }], + }; + const instance = new ReplicationConfiguration( + { ReplicationConfiguration: repConfig }, null, mockS3ServerConfig); + const result = instance.parseConfiguration(); + expect(result).toBeUndefined(); + const rules = instance.getRules()!; + expect(rules[0].account).toBeUndefined(); + }); + + it('should parse V2 XML configuration with multiple destinations', done => { + const xml = ` + + ${TEST_ROLE} + + rule1-dest-A + Enabled + images/ + + arn:aws:s3:::bucket-a + ring + 222222222222 + + + + rule1-dest-B + Enabled + docs/ + + arn:aws:s3:::bucket-b + ring + 333333333333 + + + + `; + parseString(xml, (err, parsedXml) => { + expect(err).toBeNull(); + const repConf = new ReplicationConfiguration( + parsedXml, null, mockS3ServerConfig); + const repConfErr = repConf.parseConfiguration(); + expect(repConfErr).toBeUndefined(); + const rules = repConf.getRules()!; + expect(rules.length).toEqual(2); + expect(rules[0].destination).toEqual('arn:aws:s3:::bucket-a'); + expect(rules[0].account).toEqual('222222222222'); + expect(rules[1].destination).toEqual('arn:aws:s3:::bucket-b'); + expect(rules[1].account).toEqual('333333333333'); + done(); + }); + }); + }); + + describe('getConfigXML()', () => { + it('should generate V1 XML from configuration', () => { + const config = { + role: TEST_ROLE, + destination: 'arn:aws:s3:::crr-dest', + rules: [{ + id: 'rule1', + prefix: 'docs/', + enabled: true, + storageClass: 'STANDARD', + }], + }; + const xml = ReplicationConfiguration.getConfigXML(config); + expect(xml).toContain('docs/'); + expect(xml).toContain('Enabled'); + expect(xml).toContain('STANDARD'); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); + + it('should generate V2 XML when format hint is "v2" (no priorities)', () => { + const config = { + role: TEST_ROLE, + rules: [{ + id: 'rule1', + prefix: 'docs/', + enabled: true, + storageClass: 'ring', + destination: 'arn:aws:s3:::bucket-a', + }], + format: 'v2' as const, + }; + const xml = ReplicationConfiguration.getConfigXML(config); + expect(xml).toContain('docs/'); + expect(xml).not.toContain(''); + expect(xml).toContain('arn:aws:s3:::bucket-a'); + }); + + it('should generate V2 XML for multi-destination rules', () => { + const config = { + role: TEST_ROLE, + destination: 'arn:aws:s3:::bucket-a', + rules: [ + { + id: 'rule1', + prefix: '', + enabled: true, + storageClass: 'ring', + destination: 'arn:aws:s3:::bucket-a', + account: '222222222222', + }, + { + id: 'rule2', + prefix: 'docs/', + enabled: true, + storageClass: 'ring', + destination: 'arn:aws:s3:::bucket-b', + account: '333333333333', + }, + ], + format: 'v2' as const, + }; + const xml = ReplicationConfiguration.getConfigXML(config); + expect(xml).toContain(''); + expect(xml).toContain('docs/'); + expect(xml).toContain('arn:aws:s3:::bucket-a'); + expect(xml).toContain('arn:aws:s3:::bucket-b'); + expect(xml).toContain('222222222222'); + expect(xml).toContain('333333333333'); + }); + }); + + describe('round-trip parse / getConfigXML', () => { + const parseXML = (xml: string): any => { + let parsed: any; + let parseErr: Error | undefined; + parseString(xml, (err: Error | null, result: any) => { + parseErr = err ?? undefined; + parsed = result; + }); + if (parseErr) { + throw parseErr; + } + return parsed; + }; + + const roundTrip = (xml: string) => { + const inst1 = new ReplicationConfiguration( + parseXML(xml), null, mockS3ServerConfig); + expect(inst1.parseConfiguration()).toBeUndefined(); + const meta1 = inst1.getReplicationConfiguration(); + + const emitted = ReplicationConfiguration.getConfigXML(meta1); + + const inst2 = new ReplicationConfiguration( + parseXML(emitted), null, mockS3ServerConfig); + expect(inst2.parseConfiguration()).toBeUndefined(); + const meta2 = inst2.getReplicationConfiguration(); + + expect(meta2).toEqual(meta1); + return { meta1, emitted }; + }; + + it('round-trips a V2 multi-destination configuration', () => { + const xml = ` + + ${TEST_ROLE} + + rule1 + Enabled + images/ + + arn:aws:s3:::bucket-a + ring + 222222222222 + + + + rule2 + Enabled + docs/ + + arn:aws:s3:::bucket-b + ring + 333333333333 + + + `; + const { meta1 } = roundTrip(xml); + expect(meta1.format).toEqual('v2'); + expect(meta1.rules[0].destination).toEqual('arn:aws:s3:::bucket-a'); + expect(meta1.rules[1].destination).toEqual('arn:aws:s3:::bucket-b'); + expect(meta1.rules[0].account).toEqual('222222222222'); + expect(meta1.rules[1].account).toEqual('333333333333'); + }); + + it('round-trips a V1 configuration carrying Account', () => { + const xml = ` + + ${TEST_ROLE} + + rule1 + Enabled + images/ + + arn:aws:s3:::bucket-a + ring + 222222222222 + + + `; + const { meta1 } = roundTrip(xml); + expect(meta1.format).toEqual('v1'); + expect(meta1.rules[0].destination).toEqual('arn:aws:s3:::bucket-a'); + expect(meta1.rules[0].account).toEqual('222222222222'); + }); + + it('emits pure V1 with byte-stable legacy wire shape', () => { + const xml = `` + + `` + + `${TEST_ROLE}` + + `` + + `rule1` + + `Enabled` + + `images/` + + `` + + `arn:aws:s3:::bucket-a` + + `STANDARD` + + `` + + `` + + ``; + const { emitted, meta1 } = roundTrip(xml); + expect(meta1.format).toEqual('v1'); + + const expected = `` + + `` + + `` + + `rule1` + + `images/` + + `Enabled` + + `` + + `arn:aws:s3:::bucket-a` + + `STANDARD` + + `` + + `` + + `${TEST_ROLE}` + + ``; + expect(emitted).toEqual(expected); + }); + }); + + describe('validateConfig()', () => { + it('should validate a V1 config', () => { + expect(() => ReplicationConfiguration.validateConfig({ + role: TEST_ROLE, + destination: 'arn:aws:s3:::crr-dest', + rules: [{ + prefix: '', + enabled: true, + id: 'rule1', + storageClass: 'STANDARD', + }], + })).not.toThrow(); + }); + + it('should validate a V2 config with per-rule destination and account', () => { + expect(() => ReplicationConfiguration.validateConfig({ + role: TEST_ROLE, + rules: [{ + prefix: '', + enabled: true, + id: 'rule1', + storageClass: 'ring', + destination: 'arn:aws:s3:::bucket-a', + account: '222222222222', + }], + format: 'v2', + })).not.toThrow(); + }); + }); });