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();
+ });
+ });
});