From d3d186cbc44e82089eaa057e6a5bf51848b05e98 Mon Sep 17 00:00:00 2001 From: Anurag Mittal <1321012+anurag4DSB@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:03:11 +0100 Subject: [PATCH 1/2] CLDSRV-1067: test destination Update Add unit tests for the Update handler on the in-memory backend, on a record stored with an old CreatedAt. They check: - the body replaces the whole record: new brokers and topic, defaults applied, and the old Sasl block gone when the body switches to mtls - identity fields and CreatedAt kept as stored, and a new UpdatedAt - a version 1 record written again as version 1 - a record of a newer version refused with InvalidDestination, and left untouched - an IAM user - an unknown name and another account's name: 404 with no write - a bad name, checked before the body - MalformedXML and InvalidDestination (GSSAPI included), with the record kept - the body, checked before the read - store errors - no record value in the logs Add functional tests through Create and Get. They check: - Update 200, then Get shows the new brokers and topic, the old CreatedAt and a later UpdatedAt - an unknown name 404 - an invalid body 400, with the record unchanged - a bad name 400 - anonymous 403 - with a real Vault: an IAM user with no policy gets 403, while a user allowed on one destination updates it and gets 403 on another Each IAM test makes its own IAM user and destinations, and removes the user when it ends. --- .../test/routes/notificationDestination.js | 129 ++++++++ .../unit/api/notificationDestinationUpdate.js | 299 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 tests/unit/api/notificationDestinationUpdate.js diff --git a/tests/functional/raw-node/test/routes/notificationDestination.js b/tests/functional/raw-node/test/routes/notificationDestination.js index 59793869f1..c4065e5d81 100644 --- a/tests/functional/raw-node/test/routes/notificationDestination.js +++ b/tests/functional/raw-node/test/routes/notificationDestination.js @@ -229,6 +229,7 @@ function destinationRequest(method, queryObj, requestBody, credentials = authCre const createDestination = (name, body, credentials) => destinationRequest('PUT', { name }, body, credentials); const getDestination = (name, credentials) => destinationRequest('GET', { name }, undefined, credentials); +const updateDestination = (name, body, credentials) => destinationRequest('POST', { name }, body, credentials); const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -558,6 +559,68 @@ describeOn('ListNotificationDestinations', () => { }); }); +describeOn('UpdateNotificationDestination', () => { + const newBody = saslSslBody + .replace( + /.*<\/Brokers>/, + 'broker7.example.com:9094broker8.example.com:9094', + ) + .replace('bucket-events', 'updated-events'); + + it('should replace the destination, keep CreatedAt, and answer 200 with no body', async () => { + const name = uniqueName('update'); + assert.ifError((await createDestination(name, saslSslBody)).err); + const created = await getDestination(name); + assert.ifError(created.err); + const [createdAt] = elementValues(created.res.body, 'CreatedAt'); + // UpdatedAt is to the millisecond. Let a few milliseconds pass, so + // that UpdatedAt changes. + await new Promise(resolve => setTimeout(resolve, 10)); + + const { err, res } = await updateDestination(name, newBody); + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body, ''); + + const got = await getDestination(name); + assert.ifError(got.err); + assert.deepStrictEqual(elementValues(got.res.body, 'Broker'), [ + 'broker7.example.com:9094', + 'broker8.example.com:9094', + ]); + assert.deepStrictEqual(elementValues(got.res.body, 'Topic'), ['updated-events']); + assert.deepStrictEqual(elementValues(got.res.body, 'CreatedAt'), [createdAt]); + const [updatedAt] = elementValues(got.res.body, 'UpdatedAt'); + assert.match(updatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert(Date.parse(updatedAt) > Date.parse(createdAt), `${updatedAt} is not after ${createdAt}`); + }); + + it('should answer NoSuchDestination to an unknown name', async () => { + assertError(await updateDestination(uniqueName('unknown'), newBody), 'NoSuchDestination', 404); + }); + + it('should answer InvalidDestination to an invalid body, and keep the destination', async () => { + const name = uniqueName('update-invalid'); + assert.ifError((await createDestination(name, saslSslBody)).err); + const original = await getDestination(name); + assert.ifError(original.err); + const body = newBody.replace('broker7.example.com:9094', 'broker7.example.com:0'); + assert.notStrictEqual(body, newBody); + assertError(await updateDestination(name, body), 'InvalidDestination', 400); + const got = await getDestination(name); + assert.ifError(got.err); + assert.strictEqual(got.res.body, original.res.body); + }); + + it('should answer InvalidDestination to a name outside the grammar', async () => { + assertError(await updateDestination('bad/name', newBody), 'InvalidDestination', 400); + }); + + it('should answer AccessDenied to an anonymous request', async () => { + assertError(await updateDestination(uniqueName('anonymous'), newBody, null), 'AccessDenied', 403); + }); +}); + describeIam('CreateNotificationDestination by an IAM user', () => { const iamClient = makeIamClient(); const userName = uniqueName('destination-no-policy'); @@ -662,3 +725,69 @@ describeIam('ListNotificationDestinations by an IAM user', () => { assertError(await listAs('one'), 'AccessDenied', 403); }); }); + +describeIam('UpdateNotificationDestination by an IAM user', () => { + const iamClient = makeIamClient(); + const userName = uniqueName('destination-update-no-policy'); + let name; + let credentials; + + beforeEach(async () => { + name = testName('update-denied'); + assert.ifError((await createDestination(name, saslSslBody)).err); + credentials = await createIamCaller(iamClient, userName); + }); + + afterEach(async () => { + await removeIamCaller(iamClient, userName); + }); + + it('should answer AccessDenied to a user with no policy, and keep the destination', async () => { + const body = saslSslBody.replace('bucket-events', 'denied-events'); + assertError(await updateDestination(name, body, credentials), 'AccessDenied', 403); + const got = await getDestination(name); + assert.ifError(got.err); + assert.deepStrictEqual(elementValues(got.res.body, 'Topic'), ['bucket-events']); + }); +}); + +describeIam('UpdateNotificationDestination by an IAM user allowed on one destination', () => { + const iamClient = makeIamClient(); + const userName = uniqueName('destination-update-one'); + let allowedName; + let otherName; + let credentials; + + beforeEach(async () => { + allowedName = testName('update-allowed'); + otherName = testName('update-other'); + const allowedArn = await createDestinationArn(allowedName); + assert.ifError((await createDestination(otherName, saslSslBody)).err); + credentials = await createIamCaller(iamClient, userName, { + action: 'scality:UpdateBucketNotificationDestination', + resource: allowedArn, + }); + }); + + afterEach(async () => { + await removeIamCaller(iamClient, userName); + }); + + const newBody = saslSslBody.replace('bucket-events', 'allowed-events'); + + it('should update the destination that the policy names', async () => { + const { err, res } = await updateDestination(allowedName, newBody, credentials); + assert.ifError(err); + assert.strictEqual(res.statusCode, 200); + const got = await getDestination(allowedName); + assert.ifError(got.err); + assert.deepStrictEqual(elementValues(got.res.body, 'Topic'), ['allowed-events']); + }); + + it('should answer AccessDenied to another destination, and keep it', async () => { + assertError(await updateDestination(otherName, newBody, credentials), 'AccessDenied', 403); + const got = await getDestination(otherName); + assert.ifError(got.err); + assert.deepStrictEqual(elementValues(got.res.body, 'Topic'), ['bucket-events']); + }); +}); diff --git a/tests/unit/api/notificationDestinationUpdate.js b/tests/unit/api/notificationDestinationUpdate.js new file mode 100644 index 0000000000..3441f937e3 --- /dev/null +++ b/tests/unit/api/notificationDestinationUpdate.js @@ -0,0 +1,299 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const { errors, models, storage } = require('@scality/arsenal'); +const AuthInfo = require('@scality/arsenal').auth.AuthInfo; + +const notificationDestinationUpdate = require('../../../lib/api/notificationDestinationUpdate'); +const recordStore = require('../../../lib/api/apiUtils/notificationDestination/recordStore'); +const { config } = require('../../../lib/Config'); +const constants = require('../../../constants'); +const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers'); + +const { metadata } = storage.metadata.inMemory.metadata; + +const { accountResourcesBucket: recordsBucket, notificationDestinationKeyTag: tag, splitter } = constants; +// the account of accessKey1 in makeAuthInfo +const accountId = '123456789098'; +const keyOf = name => `${accountId}${splitter}${tag}${splitter}${name}`; + +// A stored record, written long ago, that each test updates. +const storedSaslSsl = { + modelVersion: 1, + Name: 'orders', + AccountId: accountId, + Arn: `arn:scality:bucketnotif::${accountId}:orders`, + Type: 'kafka', + Connection: { + Brokers: ['broker1.example.com:9093'], + Topic: 'bucket-events', + RequiredAcks: 1, + CompressionType: 'zstd', + }, + Auth: { + Type: 'sasl_ssl', + Sasl: { Mechanism: 'SCRAM-SHA-512', Username: 'old-user-4c1e', Password: 'old-pass-4c1e' }, + Tls: { CaCertificate: 'old-ca-4c1e' }, + }, + CreatedAt: '2020-01-01T00:00:00Z', + UpdatedAt: '2020-01-01T00:00:00Z', +}; + +// The new body: other brokers and topic, mtls, and no RequiredAcks or +// CompressionType, so that the defaults apply. +const mtlsBody = + '' + + 'kafka' + + '' + + 'broker2.example.com:9094broker3.example.com:9094' + + 'new-events' + + '' + + '' + + 'mtls' + + 'sentinel-cert-5e2dsentinel-key-5e2d' + + '' + + ''; + +const accountAuthInfo = makeAuthInfo('accessKey1'); +// An IAM user whose short ID, the user ID, is not the account ID, as Vault +// gives it. makeAuthInfo gives a user the short ID of its account. +const userAuthInfo = new AuthInfo({ + canonicalID: accountAuthInfo.getCanonicalID(), + shortid: 'AIDAUSERID0000000000', + arn: `arn:aws:iam::${accountId}:user/alice`, +}); +const otherAuthInfo = makeAuthInfo('accessKey2'); + +function makeRequest(name, post) { + return { + method: 'POST', + url: `/?notification-destination&name=${encodeURIComponent(name)}`, + query: { 'notification-destination': '', name }, + headers: { host: 's3.amazonaws.com' }, + post, + actionImplicitDenies: false, + }; +} + +// Resolve with the callback arguments, so that a failed assertion fails the +// test at once instead of in a metadata callback. +function update(authInfo, request, log) { + return new Promise(resolve => notificationDestinationUpdate(authInfo, request, log, (...args) => resolve(args))); +} + +// The in-memory store keeps the value it is given. Store a copy, so that a +// handler that edited the stored record in place could not edit the +// expected one too. +function seed(record, log) { + return new Promise((resolve, reject) => + recordStore.putDestination(accountId, record.Name, structuredClone(record), log, err => + err ? reject(err) : resolve(), + ), + ); +} + +function storedRecord(name) { + const keys = metadata.keyMaps.get(recordsBucket); + return keys ? keys.get(keyOf(name)) : undefined; +} + +describe('notificationDestinationUpdate', () => { + let log; + let savedBackend; + let savedGlobalDestinations; + + beforeEach(async () => { + // The tests read the in-memory store, so the record store runs on + // mem, whatever S3BACKEND says. stop() drops its client after each + // test. + savedBackend = config.backends.metadata; + config.backends.metadata = 'mem'; + recordStore.stop(); + cleanup(); + log = new DummyRequestLogger(); + savedGlobalDestinations = config.bucketNotificationDestinations; + config.bucketNotificationDestinations = [{ resource: 'target1', type: 'dummy', host: 'localhost:6000' }]; + await seed(storedSaslSsl, log); + }); + + afterEach(() => { + recordStore.stop(); + config.backends.metadata = savedBackend; + config.bucketNotificationDestinations = savedGlobalDestinations; + sinon.restore(); + }); + + it('should replace the record, keep CreatedAt, and answer with no body', async () => { + const before = Date.now(); + const args = await update(accountAuthInfo, makeRequest('orders', mtlsBody), log); + assert.strictEqual(args.length, 1, 'Update answers callback(err)'); + assert.ifError(args[0]); + + const { UpdatedAt, ...rest } = storedRecord('orders'); + assert.deepStrictEqual(rest, { + modelVersion: 1, + Name: 'orders', + AccountId: accountId, + Arn: `arn:scality:bucketnotif::${accountId}:orders`, + Type: 'kafka', + Connection: { + Brokers: ['broker2.example.com:9094', 'broker3.example.com:9094'], + Topic: 'new-events', + RequiredAcks: -1, + CompressionType: 'none', + }, + Auth: { + Type: 'mtls', + Tls: { ClientCertificate: 'sentinel-cert-5e2d', ClientKey: 'sentinel-key-5e2d' }, + }, + CreatedAt: '2020-01-01T00:00:00Z', + }); + assert.match(UpdatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + assert(Date.parse(UpdatedAt) >= before); + }); + + it('should rewrite a version 1 record with version 1, and drop any other stored field', async () => { + await seed({ ...storedSaslSsl, modelVersion: 1, Extra: 'stale' }, log); + const [err] = await update(accountAuthInfo, makeRequest('orders', mtlsBody), log); + assert.ifError(err); + const record = storedRecord('orders'); + assert.strictEqual(record.modelVersion, 1); + assert.strictEqual(record.Extra, undefined); + }); + + it('should refuse a record written by a newer server, and leave it untouched', async () => { + const newer = { ...storedSaslSsl, modelVersion: 2 }; + await seed(newer, log); + const put = sinon.spy(recordStore, 'putDestination'); + const [err] = await update(accountAuthInfo, makeRequest('orders', mtlsBody), log); + assert.strictEqual(err?.message, 'InvalidDestination'); + assert.strictEqual(err.code, 400); + assert.match(err.description, /written by a newer server \(record version 2, this server knows 1\)/); + assert.strictEqual(put.called, false); + assert.deepStrictEqual(storedRecord('orders'), newer); + }); + + it('should update the destination of the account of an IAM user, never its user ID', async () => { + const [err] = await update(userAuthInfo, makeRequest('orders', mtlsBody), log); + assert.ifError(err); + assert.strictEqual(storedRecord('orders').Connection.Topic, 'new-events'); + }); + + it('should answer NoSuchDestination to an unknown name, and write nothing', async () => { + const put = sinon.spy(recordStore, 'putDestination'); + const [err] = await update(accountAuthInfo, makeRequest('unknown', mtlsBody), log); + assert.strictEqual(err?.message, 'NoSuchDestination'); + assert.strictEqual(err.code, 404); + assert.strictEqual(put.called, false); + assert.strictEqual(storedRecord('unknown'), undefined); + }); + + it('should answer NoSuchDestination to the name of another account', async () => { + const [err] = await update(otherAuthInfo, makeRequest('orders', mtlsBody), log); + assert.strictEqual(err?.message, 'NoSuchDestination'); + assert.strictEqual(storedRecord('orders').Connection.Topic, 'bucket-events'); + }); + + it('should update a destination whose name a global destination uses', async () => { + // Create refuses such a name. Update still updates a record that holds it. + await seed({ ...storedSaslSsl, Name: 'target1', Arn: `arn:scality:bucketnotif::${accountId}:target1` }, log); + const [err] = await update(accountAuthInfo, makeRequest('target1', mtlsBody), log); + assert.ifError(err); + assert.strictEqual(storedRecord('target1').Connection.Topic, 'new-events'); + }); + + ['bad/name', 'a'.repeat(models.NotificationDestination.nameMaxLength + 1), '*'].forEach(name => { + it(`should answer InvalidDestination to the name '${name.slice(0, 20)}'`, async () => { + const get = sinon.spy(recordStore, 'getDestination'); + const [err] = await update(accountAuthInfo, makeRequest(name, mtlsBody), log); + assert.strictEqual(err?.message, 'InvalidDestination'); + assert.strictEqual(err.code, 400); + assert.strictEqual(get.called, false); + }); + }); + + it('should check the name before the body', async () => { + const [err] = await update(accountAuthInfo, makeRequest('bad/name', ''), log); + assert.strictEqual(err?.message, 'InvalidDestination'); + }); + + [undefined, ''].forEach(post => { + it(`should answer MalformedXML to the body ${JSON.stringify(post)}, and keep the record`, async () => { + const [err] = await update(accountAuthInfo, makeRequest('orders', post), log); + assert.strictEqual(err?.message, 'MalformedXML'); + assert.deepStrictEqual(storedRecord('orders'), storedSaslSsl); + }); + }); + + [ + [ + 'a broker with port 0', + ['broker2.example.com:9094', 'broker2.example.com:0'], + /^Connection\.Brokers\.Broker /, + ], + [ + 'the GSSAPI mechanism', + [ + /.*<\/Auth>/, + 'sasl_sslGSSAPI' + + 'up', + ], + /Sasl\.Mechanism/, + ], + ['mtls with no client key', ['sentinel-key-5e2d', ''], /ClientKey/], + ].forEach(([what, [from, to], description]) => { + it(`should answer InvalidDestination to ${what}, and keep the record`, async () => { + const body = mtlsBody.replace(from, to); + assert.notStrictEqual(body, mtlsBody); + const [err] = await update(accountAuthInfo, makeRequest('orders', body), log); + assert.strictEqual(err?.message, 'InvalidDestination'); + assert.match(err.description, description); + assert.deepStrictEqual(storedRecord('orders'), storedSaslSsl); + }); + }); + + it('should check the body before reading the record', async () => { + const get = sinon.spy(recordStore, 'getDestination'); + const [err] = await update(accountAuthInfo, makeRequest('unknown', ''), log); + assert.strictEqual(err?.message, 'MalformedXML'); + assert.strictEqual(get.called, false); + }); + + it('should pass a record store read error through, and write nothing', async () => { + sinon.stub(recordStore, 'getDestination').callsArgWith(3, errors.InternalError); + const put = sinon.spy(recordStore, 'putDestination'); + const [err] = await update(accountAuthInfo, makeRequest('orders', mtlsBody), log); + assert.strictEqual(err?.message, 'InternalError'); + assert.strictEqual(put.called, false); + }); + + it('should pass a record store write error through', async () => { + sinon.stub(recordStore, 'putDestination').callsArgWith(4, errors.InternalError); + const [err] = await update(accountAuthInfo, makeRequest('orders', mtlsBody), log); + assert.strictEqual(err?.message, 'InternalError'); + }); + + it('should never log the record', async () => { + const spyLog = new DummyRequestLogger(); + const levels = ['trace', 'debug', 'info', 'warn', 'error']; + levels.forEach(level => sinon.spy(spyLog, level)); + await update(accountAuthInfo, makeRequest('orders', mtlsBody), spyLog); + await update(accountAuthInfo, makeRequest('unknown', mtlsBody), spyLog); + await update( + accountAuthInfo, + makeRequest('orders', mtlsBody.replace('broker2.example.com:9094', 'x:0')), + spyLog, + ); + const logged = JSON.stringify(levels.flatMap(level => spyLog[level].args)); + [ + 'sentinel-cert-5e2d', + 'sentinel-key-5e2d', + 'old-pass-4c1e', + 'old-user-4c1e', + 'old-ca-4c1e', + 'broker', + 'events', + ].forEach(value => assert.doesNotMatch(logged, new RegExp(value))); + assert.match(logged, /orders/); + assert.match(logged, new RegExp(accountId)); + }); +}); From 40e765ba5badbe4f05bcf35d563377552bc07bbb Mon Sep 17 00:00:00 2001 From: Anurag Mittal <1321012+anurag4DSB@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:03:11 +0100 Subject: [PATCH 2/2] CLDSRV-1067: add destination Update notificationDestinationUpdate: - checks the name, then checks the body with the checker that Create uses - reads the record of the requester's account. NoSuchDestination stops the update. - writes the whole record again: Type, Connection and Auth from the body, Name, AccountId, Arn and CreatedAt as stored, UpdatedAt now, and the record version that this server writes - leaves a record of a newer version untouched, because it may hold fields that this server would drop. That record gets InvalidDestination, which says to update it from a server that runs the newer version. - answers 200 with no body, and logs the name and the account only - reports its call to the Prometheus metrics, as the bucket APIs do Update writes the record version of the shared request helpers, as Create does. --- lib/api/api.js | 2 + lib/api/notificationDestinationUpdate.js | 101 +++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 lib/api/notificationDestinationUpdate.js diff --git a/lib/api/api.js b/lib/api/api.js index a14b0f9d93..18a4ac28e0 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -54,6 +54,7 @@ const multipartDelete = require('./multipartDelete'); const notificationDestinationCreate = require('./notificationDestinationCreate'); const notificationDestinationGet = require('./notificationDestinationGet'); const notificationDestinationList = require('./notificationDestinationList'); +const notificationDestinationUpdate = require('./notificationDestinationUpdate'); const objectCopy = require('./objectCopy'); const { objectDelete } = require('./objectDelete'); const objectDeleteTagging = require('./objectDeleteTagging'); @@ -647,6 +648,7 @@ const api = { notificationDestinationCreate, notificationDestinationGet, notificationDestinationList, + notificationDestinationUpdate, objectDelete, objectDeleteTagging, objectGet, diff --git a/lib/api/notificationDestinationUpdate.js b/lib/api/notificationDestinationUpdate.js new file mode 100644 index 0000000000..7736269e33 --- /dev/null +++ b/lib/api/notificationDestinationUpdate.js @@ -0,0 +1,101 @@ +const { errorInstances } = require('@scality/arsenal'); + +const recordStore = require('./apiUtils/notificationDestination/recordStore'); +const parseDestination = require('./apiUtils/notificationDestination/parseDestination'); +const { nameError, recordModelVersion, requesterAccountId } = require('./apiUtils/notificationDestination/request'); +const parseXML = require('../utilities/parseXML'); +const monitoring = require('../utilities/monitoringHandler'); + +const METRICS_ACTION = 'updateNotificationDestination'; + +/** + * notificationDestinationUpdate - replace a bucket notification destination + * of the account of the requester with the body, keeping its name, account, + * ARN and creation time + * @param {AuthInfo} authInfo - Instance of AuthInfo class with requester's info + * @param {object} request - http request object + * @param {object} log - Werelogs logger + * @param {function} callback - callback(err) + * @return {undefined} + */ +function notificationDestinationUpdate(authInfo, request, log, callback) { + log.debug('processing request', { method: 'notificationDestinationUpdate' }); + const name = request.query.name; + + const invalidName = nameError(name); + if (invalidName) { + log.debug('invalid destination name', { method: 'notificationDestinationUpdate' }); + monitoring.promMetrics('POST', request.bucketName, 400, METRICS_ACTION); + return process.nextTick(callback, invalidName); + } + const accountId = requesterAccountId(authInfo); + const logFields = { method: 'notificationDestinationUpdate', destinationName: name, accountId }; + + return parseXML(request.post, log, (parseErr, parsedXml) => { + if (parseErr) { + monitoring.promMetrics('POST', request.bucketName, parseErr.code, METRICS_ACTION); + return callback(parseErr); + } + const fields = parseDestination(parsedXml); + if (fields.error) { + log.debug('invalid destination', { ...logFields, error: fields.error.description }); + monitoring.promMetrics('POST', request.bucketName, fields.error.code, METRICS_ACTION); + return callback(fields.error); + } + // Read, then write. bucketd has no conditional put. Thus, when an + // update reads before a delete and writes after it, the update + // answers 200 and restores the record. + return recordStore.getDestination(accountId, name, log, (getErr, stored) => { + if (getErr) { + if (getErr.is?.NoSuchDestination) { + log.debug('destination not found', logFields); + } else { + log.error('could not read the destination record', { ...logFields, error: getErr }); + } + monitoring.promMetrics('POST', request.bucketName, getErr.code, METRICS_ACTION); + return callback(getErr); + } + // A record of a newer version may hold fields that this server + // would drop. Leave it to a server that knows that version. + if (stored.modelVersion > recordModelVersion) { + log.debug('destination record of a newer version', { + ...logFields, + modelVersion: stored.modelVersion, + }); + const newerErr = errorInstances.InvalidDestination.customizeDescription( + 'This destination was written by a newer server (record version ' + + `${stored.modelVersion}, this server knows ${recordModelVersion}). ` + + 'Update it from a server running the newer version.', + ); + monitoring.promMetrics('POST', request.bucketName, newerErr.code, METRICS_ACTION); + return callback(newerErr); + } + // Replace the whole record, in the version that this server + // writes. Keep only the identity fields and the creation time + // from the stored record. + const record = { + modelVersion: recordModelVersion, + Name: stored.Name, + AccountId: stored.AccountId, + Arn: stored.Arn, + Type: fields.Type, + Connection: fields.Connection, + Auth: fields.Auth, + CreatedAt: stored.CreatedAt, + UpdatedAt: new Date().toJSON(), + }; + return recordStore.putDestination(accountId, name, record, log, putErr => { + if (putErr) { + log.error('could not write the destination record', { ...logFields, error: putErr }); + monitoring.promMetrics('POST', request.bucketName, putErr.code, METRICS_ACTION); + return callback(putErr); + } + log.debug('destination updated', logFields); + monitoring.promMetrics('POST', request.bucketName, '200', METRICS_ACTION); + return callback(null); + }); + }); + }); +} + +module.exports = notificationDestinationUpdate;