diff --git a/lib/api/api.js b/lib/api/api.js
index 7a29281da3..a14b0f9d93 100644
--- a/lib/api/api.js
+++ b/lib/api/api.js
@@ -53,6 +53,7 @@ const { multiObjectDelete } = require('./multiObjectDelete');
const multipartDelete = require('./multipartDelete');
const notificationDestinationCreate = require('./notificationDestinationCreate');
const notificationDestinationGet = require('./notificationDestinationGet');
+const notificationDestinationList = require('./notificationDestinationList');
const objectCopy = require('./objectCopy');
const { objectDelete } = require('./objectDelete');
const objectDeleteTagging = require('./objectDeleteTagging');
@@ -645,6 +646,7 @@ const api = {
multipartDelete,
notificationDestinationCreate,
notificationDestinationGet,
+ notificationDestinationList,
objectDelete,
objectDeleteTagging,
objectGet,
diff --git a/lib/api/notificationDestinationList.js b/lib/api/notificationDestinationList.js
new file mode 100644
index 0000000000..4996b05627
--- /dev/null
+++ b/lib/api/notificationDestinationList.js
@@ -0,0 +1,101 @@
+const { errors, errorInstances, s3middleware } = require('@scality/arsenal');
+
+const { listingHardLimit } = require('../../constants');
+const recordStore = require('./apiUtils/notificationDestination/recordStore');
+const { requesterAccountId } = require('./apiUtils/notificationDestination/request');
+const { destinationXml } = require('./apiUtils/notificationDestination/xml');
+const monitoring = require('../utilities/monitoringHandler');
+
+const { escapeForXml } = s3middleware;
+
+const METRICS_ACTION = 'listNotificationDestinations';
+const defaultMaxItems = 100;
+
+/**
+ * Return the number of names that a page holds. It is 100 when max-items is
+ * absent. Else it is max-items, which must be digits only and from 1 to the
+ * listing limit, 1000.
+ * @param {*} maxItems - the max-items query parameter
+ * @return {number|null} - the number of names, or null when max-items is
+ * not valid
+ */
+function pageSize(maxItems) {
+ if (maxItems === undefined) {
+ return defaultMaxItems;
+ }
+ // A repeated parameter is an array, which a pattern test would coerce.
+ if (typeof maxItems !== 'string' || !/^[0-9]+$/.test(maxItems)) {
+ return null;
+ }
+ const size = Number.parseInt(maxItems, 10);
+ return size >= 1 && size <= listingHardLimit ? size : null;
+}
+
+/**
+ * notificationDestinationList - list the bucket notification destinations of
+ * the account of the requester, in name order, each with its Auth reduced to
+ * its Type
+ * @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, xml)
+ * @return {undefined}
+ */
+function notificationDestinationList(authInfo, request, log, callback) {
+ log.debug('processing request', { method: 'notificationDestinationList' });
+ const { query } = request;
+
+ const maxKeys = pageSize(query['max-items']);
+ if (maxKeys === null) {
+ log.debug('invalid max-items', { method: 'notificationDestinationList' });
+ const err = errorInstances.InvalidArgument.customizeDescription(
+ `max-items must be a whole number from 1 to ${listingHardLimit}.`,
+ );
+ monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION);
+ return process.nextTick(callback, err, null);
+ }
+ const { marker } = query;
+ if (marker !== undefined && typeof marker !== 'string') {
+ log.debug('invalid marker', { method: 'notificationDestinationList' });
+ const err = errorInstances.InvalidArgument.customizeDescription('marker must be given once.');
+ monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION);
+ return process.nextTick(callback, err, null);
+ }
+ const accountId = requesterAccountId(authInfo);
+ const logFields = { method: 'notificationDestinationList', accountId, maxItems: maxKeys };
+ const params = { maxKeys };
+ if (marker !== undefined) {
+ params.marker = marker;
+ }
+
+ return recordStore.listDestinations(accountId, params, log, (err, result) => {
+ if (err) {
+ log.error('could not list the destination records', { ...logFields, error: err });
+ monitoring.promMetrics('GET', request.bucketName, err.code, METRICS_ACTION);
+ return callback(err, null);
+ }
+ const { destinations, isTruncated, nextMarker } = result;
+ let xml;
+ try {
+ xml = [
+ '',
+ '',
+ `${isTruncated}`,
+ isTruncated && nextMarker !== undefined ? `${escapeForXml(nextMarker)}` : '',
+ ...destinations.map(destination => destinationXml(destination.record)),
+ '',
+ ].join('');
+ } catch (xmlErr) {
+ // The handler cannot answer a record that lacks a block. Never let
+ // the error escape the record store callback.
+ log.error('could not build the destination list answer', { ...logFields, error: xmlErr.message });
+ monitoring.promMetrics('GET', request.bucketName, errors.InternalError.code, METRICS_ACTION);
+ return callback(errors.InternalError, null);
+ }
+ log.debug('destinations listed', { ...logFields, count: destinations.length, isTruncated });
+ monitoring.promMetrics('GET', request.bucketName, '200', METRICS_ACTION);
+ return callback(null, xml);
+ });
+}
+
+module.exports = notificationDestinationList;
diff --git a/tests/functional/raw-node/test/routes/notificationDestination.js b/tests/functional/raw-node/test/routes/notificationDestination.js
index fca88d4f2c..59793869f1 100644
--- a/tests/functional/raw-node/test/routes/notificationDestination.js
+++ b/tests/functional/raw-node/test/routes/notificationDestination.js
@@ -455,6 +455,109 @@ describeOn('GetNotificationDestination', () => {
});
});
+describeOn('ListNotificationDestinations', () => {
+ const listDestinations = (queryObj, credentials) => destinationRequest('GET', queryObj, undefined, credentials);
+ const listedNames = body =>
+ [...body.matchAll(/([^<]*)<\/Name>/g)].map(match => match[1]);
+ // The other tests of this file create names of their own in the same
+ // account and never delete them. Thus the names here share one prefix
+ // that no other test uses, and each page starts after that prefix. Each
+ // test gets a prefix and destinations of its own.
+ const lisa = getCredentials('lisa');
+ const lisaCredentials = { accessKey: lisa.accessKeyId, secretKey: lisa.secretAccessKey };
+ let prefix;
+ let names;
+ let lisaName;
+
+ beforeEach(async () => {
+ prefix = testName('zz-list');
+ names = ['a', 'b', 'c'].map(suffix => `${prefix}-${suffix}`);
+ lisaName = `${prefix}-lisa`;
+ assert.notStrictEqual(lisaCredentials.accessKey, authCredentials.accessKey, 'a second account is needed');
+ for (const name of [names[2], names[0], names[1]]) {
+ assert.ifError((await createDestination(name, saslSslBody)).err);
+ }
+ assert.ifError((await createDestination(lisaName, saslSslBody, lisaCredentials)).err);
+ });
+
+ it('should page through three names with max-items 2, NextMarker then marker', async () => {
+ const first = await listDestinations({ 'max-items': '2', 'marker': `${prefix}-` });
+ assert.ifError(first.err);
+ assert.strictEqual(first.res.statusCode, 200);
+ assert.match(
+ first.res.body,
+ /^<\?xml version="1\.0" encoding="UTF-8"\?>true<\/IsTruncated>/,
+ );
+ assert.doesNotMatch(first.res.body, /xmlns/);
+ assert.deepStrictEqual(listedNames(first.res.body), names.slice(0, 2));
+ assert.deepStrictEqual(elementValues(first.res.body, 'NextMarker'), [names[1]]);
+
+ const second = await listDestinations({ 'max-items': '2', 'marker': names[1] });
+ assert.ifError(second.err);
+ const listed = listedNames(second.res.body);
+ assert.strictEqual(listed[0], names[2]);
+ assert.deepStrictEqual(
+ listed.filter(name => name.startsWith(prefix)),
+ [names[2]],
+ );
+ // A name that sorts after these may exist. NextMarker follows IsTruncated.
+ const [isTruncated] = elementValues(second.res.body, 'IsTruncated');
+ assert.deepStrictEqual(
+ elementValues(second.res.body, 'NextMarker'),
+ isTruncated === 'true' ? [listed[listed.length - 1]] : [],
+ );
+ });
+
+ it('should answer each entry as Get does, with no secret', async () => {
+ const { err, res } = await listDestinations({ 'max-items': '1', 'marker': `${prefix}-` });
+ assert.ifError(err);
+ const got = await getDestination(names[0]);
+ assert.ifError(got.err);
+ const entry = got.res.body.replace('', '');
+ assert.match(res.body, new RegExp(escapeRegExp(entry)));
+ assert.doesNotMatch(res.body, /||||secret/);
+ });
+
+ it('should answer IsTruncated false and no entry after the last name', async () => {
+ // No name of at most 128 characters sorts after 128 z.
+ const { err, res } = await listDestinations({ marker: 'z'.repeat(128) });
+ assert.ifError(err);
+ assert.strictEqual(
+ res.body,
+ '' +
+ 'false' +
+ '',
+ );
+ });
+
+ it('should never list the destinations of another account', async () => {
+ const mine = await listDestinations({ 'max-items': '1000', 'marker': `${prefix}-` });
+ assert.ifError(mine.err);
+ const myNames = listedNames(mine.res.body);
+ assert(!myNames.includes(lisaName), mine.res.body);
+ assert(myNames.includes(names[0]));
+
+ const theirs = await listDestinations({ 'max-items': '1000', 'marker': `${prefix}-` }, lisaCredentials);
+ assert.ifError(theirs.err);
+ assert.deepStrictEqual(
+ listedNames(theirs.res.body).filter(name => name.startsWith(prefix)),
+ [lisaName],
+ );
+ const [lisaAccountId] = elementValues(theirs.res.body, 'AccountId');
+ assert.notStrictEqual(lisaAccountId, elementValues(mine.res.body, 'AccountId')[0]);
+ });
+
+ ['0', '1001', 'abc', '2.5', '', '+5'].forEach(maxItems => {
+ it(`should answer InvalidArgument to max-items '${maxItems}'`, async () => {
+ assertError(await listDestinations({ 'max-items': maxItems }), 'InvalidArgument', 400);
+ });
+ });
+
+ it('should answer AccessDenied to an anonymous request', async () => {
+ assertError(await listDestinations({}, null), 'AccessDenied', 403);
+ });
+});
+
describeIam('CreateNotificationDestination by an IAM user', () => {
const iamClient = makeIamClient();
const userName = uniqueName('destination-no-policy');
@@ -506,3 +609,56 @@ describeIam('GetNotificationDestination by an IAM user', () => {
assertError(await getDestination(otherName, credentials), 'AccessDenied', 403);
});
});
+
+describeIam('ListNotificationDestinations by an IAM user', () => {
+ const iamClient = makeIamClient();
+ // List is authorized on every destination of the account. A policy on
+ // the account wildcard allows it. A policy on one destination does not.
+ const userNames = {
+ wildcard: uniqueName('destination-list-all'),
+ one: uniqueName('destination-list-one'),
+ };
+ let listedName;
+ let credentials;
+
+ beforeEach(async () => {
+ listedName = testName('list-iam');
+ const destinationArn = await createDestinationArn(listedName);
+ const resources = {
+ wildcard: destinationArn.replace(/[^:]*$/, '*'),
+ one: destinationArn,
+ };
+ credentials = {};
+ for (const [kind, userName] of Object.entries(userNames)) {
+ credentials[kind] = await createIamCaller(iamClient, userName, {
+ action: 'scality:ListBucketNotificationDestinations',
+ resource: resources[kind],
+ });
+ }
+ });
+
+ afterEach(async () => {
+ for (const userName of Object.values(userNames)) {
+ await removeIamCaller(iamClient, userName);
+ }
+ });
+
+ const listAs = kind =>
+ destinationRequest(
+ 'GET',
+ { 'max-items': '1', 'marker': listedName.slice(0, -1) },
+ undefined,
+ credentials[kind],
+ );
+
+ it('should list for a user allowed on every destination of the account', async () => {
+ const { err, res } = await listAs('wildcard');
+ assert.ifError(err);
+ assert.strictEqual(res.statusCode, 200);
+ assert.deepStrictEqual(elementValues(res.body, 'Name'), [listedName]);
+ });
+
+ it('should answer AccessDenied to a user allowed on one destination only', async () => {
+ assertError(await listAs('one'), 'AccessDenied', 403);
+ });
+});
diff --git a/tests/unit/api/notificationDestinationList.js b/tests/unit/api/notificationDestinationList.js
new file mode 100644
index 0000000000..ff0c79be4d
--- /dev/null
+++ b/tests/unit/api/notificationDestinationList.js
@@ -0,0 +1,316 @@
+const assert = require('assert');
+const sinon = require('sinon');
+const { errors } = require('@scality/arsenal');
+const AuthInfo = require('@scality/arsenal').auth.AuthInfo;
+
+const notificationDestinationCreate = require('../../../lib/api/notificationDestinationCreate');
+const notificationDestinationList = require('../../../lib/api/notificationDestinationList');
+const recordStore = require('../../../lib/api/apiUtils/notificationDestination/recordStore');
+const { config } = require('../../../lib/Config');
+const { listingHardLimit } = require('../../../constants');
+const { cleanup, DummyRequestLogger, makeAuthInfo } = require('../helpers');
+
+// the accounts of accessKey1 and accessKey2 in makeAuthInfo
+const accountId = '123456789098';
+const otherAccountId = '234567890987';
+
+const secrets = ['sentinel-user-5c1a', 'sentinel-pass-5c1a', 'sentinel-ca-5c1a'];
+
+const saslSslBody =
+ '' +
+ 'kafka' +
+ '' +
+ 'broker1.example.com:9093' +
+ 'bucket-events' +
+ '1zstd' +
+ '' +
+ '' +
+ 'sasl_ssl' +
+ `SCRAM-SHA-512${secrets[0]}` +
+ `${secrets[1]}` +
+ `${secrets[2]}` +
+ '' +
+ '';
+
+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 createRequest(name) {
+ return {
+ method: 'PUT',
+ url: `/?notification-destination&name=${encodeURIComponent(name)}`,
+ query: { 'notification-destination': '', name },
+ headers: { host: 's3.amazonaws.com' },
+ post: saslSslBody,
+ actionImplicitDenies: false,
+ };
+}
+
+function listRequest(params = {}) {
+ return {
+ method: 'GET',
+ url: '/?notification-destination',
+ query: { 'notification-destination': '', ...params },
+ headers: { host: 's3.amazonaws.com' },
+ actionImplicitDenies: false,
+ };
+}
+
+// Resolve with the callback arguments, so that a failed assertion fails the
+// test at once instead of in a metadata callback.
+function call(handler, authInfo, request, log) {
+ return new Promise(resolve => handler(authInfo, request, log, (...args) => resolve(args)));
+}
+
+const list = (authInfo, params, log) => call(notificationDestinationList, authInfo, listRequest(params), log);
+
+async function createAll(authInfo, names, log) {
+ for (const name of names) {
+ const [err] = await call(notificationDestinationCreate, authInfo, createRequest(name), log);
+ assert.ifError(err);
+ }
+}
+
+// The text of each element of that name in an XML body.
+function elementValues(body, element) {
+ return [...body.matchAll(new RegExp(`<${element}>([^<]*)${element}>`, 'g'))].map(match => match[1]);
+}
+
+const listedNames = xml => [...xml.matchAll(/([^<]*)<\/Name>/g)].map(match => match[1]);
+
+describe('notificationDestinationList', () => {
+ let log;
+ let savedBackend;
+
+ beforeEach(() => {
+ // 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();
+ });
+
+ afterEach(() => {
+ recordStore.stop();
+ config.backends.metadata = savedBackend;
+ sinon.restore();
+ });
+
+ it('should answer IsTruncated false and no entry to an account with no destination', async () => {
+ const args = await list(accountAuthInfo, {}, log);
+ assert.strictEqual(args.length, 2, 'List answers callback(err, xml)');
+ const [err, xml] = args;
+ assert.ifError(err);
+ assert.strictEqual(
+ xml,
+ '' +
+ 'false' +
+ '',
+ );
+ });
+
+ it('should answer every destination in name order, as Get answers each, with no secret', async () => {
+ await createAll(accountAuthInfo, ['orders', 'audit', 'billing'], log);
+ const [err, xml] = await list(accountAuthInfo, {}, log);
+ assert.ifError(err);
+ assert.match(
+ xml,
+ /^<\?xml version="1\.0" encoding="UTF-8"\?>false<\/IsTruncated>.*<\/NotificationDestination><\/ListNotificationDestinationsResult>$/,
+ );
+ assert.doesNotMatch(xml, /xmlns|/);
+ assert.deepStrictEqual(listedNames(xml), ['audit', 'billing', 'orders']);
+ assert.deepStrictEqual(elementValues(xml, 'AccountId'), [accountId, accountId, accountId]);
+ assert.match(
+ xml,
+ new RegExp(
+ `auditarn:scality:bucketnotif::${accountId}:audit` +
+ `${accountId}kafka` +
+ 'broker1.example.com:9093bucket-events' +
+ '1zstd' +
+ 'sasl_ssl[^<]+[^<]+' +
+ '',
+ ),
+ );
+ secrets.forEach(secret => assert.doesNotMatch(xml, new RegExp(secret)));
+ assert.doesNotMatch(xml, /|||/);
+ });
+
+ it('should list the destinations of the account of an IAM user', async () => {
+ await createAll(accountAuthInfo, ['orders'], log);
+ const [err, xml] = await list(userAuthInfo, {}, log);
+ assert.ifError(err);
+ assert.deepStrictEqual(listedNames(xml), ['orders']);
+ });
+
+ it('should never list the destinations of another account', async () => {
+ await createAll(otherAuthInfo, ['other-a', 'other-b'], log);
+ await createAll(accountAuthInfo, ['mine'], log);
+ const [err, xml] = await list(accountAuthInfo, {}, log);
+ assert.ifError(err);
+ assert.deepStrictEqual(listedNames(xml), ['mine']);
+ const [otherErr, otherXml] = await list(otherAuthInfo, {}, log);
+ assert.ifError(otherErr);
+ assert.deepStrictEqual(listedNames(otherXml), ['other-a', 'other-b']);
+ assert.deepStrictEqual(elementValues(otherXml, 'AccountId'), [otherAccountId, otherAccountId]);
+ });
+
+ it('should page through three names with max-items 2, NextMarker then marker', async () => {
+ await createAll(accountAuthInfo, ['c-dest', 'a-dest', 'b-dest'], log);
+ const [err, xml] = await list(accountAuthInfo, { 'max-items': '2' }, log);
+ assert.ifError(err);
+ assert.match(
+ xml,
+ /true<\/IsTruncated>b-dest<\/NextMarker>/,
+ );
+ assert.deepStrictEqual(listedNames(xml), ['a-dest', 'b-dest']);
+ const [nextMarker] = elementValues(xml, 'NextMarker');
+
+ const [nextErr, nextXml] = await list(accountAuthInfo, { 'max-items': '2', 'marker': nextMarker }, log);
+ assert.ifError(nextErr);
+ assert.match(
+ nextXml,
+ /false<\/IsTruncated>/,
+ );
+ assert.doesNotMatch(nextXml, //);
+ assert.deepStrictEqual(listedNames(nextXml), ['c-dest']);
+ });
+
+ it('should list the names after a marker that is not a destination name', async () => {
+ await createAll(accountAuthInfo, ['a-dest', 'b-dest', 'c-dest'], log);
+ const [err, xml] = await list(accountAuthInfo, { marker: 'a-dest0' }, log);
+ assert.ifError(err);
+ assert.deepStrictEqual(listedNames(xml), ['b-dest', 'c-dest']);
+ });
+
+ it('should escape NextMarker', async () => {
+ // No valid name needs escaping, so the store gives one that does.
+ const record = {
+ Name: 'a',
+ Arn: `arn:scality:bucketnotif::${accountId}:a`,
+ AccountId: accountId,
+ Type: 'kafka',
+ Connection: { Brokers: ['b:1'], Topic: 't', RequiredAcks: 1, CompressionType: 'none' },
+ Auth: { Type: 'none' },
+ CreatedAt: '2026-11-10T09:15:00Z',
+ UpdatedAt: '2026-11-10T09:15:00Z',
+ };
+ sinon
+ .stub(recordStore, 'listDestinations')
+ .callsArgWith(3, null, { destinations: [{ name: 'a', record }], isTruncated: true, nextMarker: 'a&' });
+ const [err, xml] = await list(accountAuthInfo, { 'max-items': '1' }, log);
+ assert.ifError(err);
+ assert.match(xml, /true<\/IsTruncated>a&<b><\/NextMarker>/);
+ });
+
+ it('should ask the store for 100 names when max-items is absent', async () => {
+ const listing = sinon.spy(recordStore, 'listDestinations');
+ const [err] = await list(accountAuthInfo, {}, log);
+ assert.ifError(err);
+ assert.strictEqual(listing.calledOnce, true);
+ assert.strictEqual(listing.firstCall.args[0], accountId);
+ assert.deepStrictEqual(listing.firstCall.args[1], { maxKeys: 100 });
+ });
+
+ it('should answer 100 entries and NextMarker when max-items is absent', async () => {
+ const destinations = Array.from({ length: 101 }, (_, i) => `dest-${String(i).padStart(3, '0')}`);
+ await createAll(accountAuthInfo, destinations, log);
+ const [err, xml] = await list(accountAuthInfo, {}, log);
+ assert.ifError(err);
+ assert.deepStrictEqual(listedNames(xml), destinations.slice(0, 100));
+ assert.deepStrictEqual(elementValues(xml, 'IsTruncated'), ['true']);
+ assert.deepStrictEqual(elementValues(xml, 'NextMarker'), ['dest-099']);
+ });
+
+ [
+ ['1', 1],
+ [String(listingHardLimit), listingHardLimit],
+ ].forEach(([maxItems, maxKeys]) => {
+ it(`should ask the store for ${maxKeys} names with max-items ${maxItems}`, async () => {
+ const listing = sinon.spy(recordStore, 'listDestinations');
+ const [err] = await list(accountAuthInfo, { 'max-items': maxItems, 'marker': 'm' }, log);
+ assert.ifError(err);
+ assert.deepStrictEqual(listing.firstCall.args[1], { maxKeys, marker: 'm' });
+ });
+ });
+
+ [
+ ['0', '0'],
+ ['above the listing limit', String(listingHardLimit + 1)],
+ ['abc', 'abc'],
+ ['2.5', '2.5'],
+ ['empty', ''],
+ ['a leading plus', '+5'],
+ ['a minus', '-1'],
+ ['a space', ' 5'],
+ ['an exponent', '1e2'],
+ ['a repeated parameter', ['2', '3']],
+ ].forEach(([what, maxItems]) => {
+ it(`should answer InvalidArgument to max-items ${what}, with no listing`, async () => {
+ const listing = sinon.spy(recordStore, 'listDestinations');
+ const args = await list(accountAuthInfo, { 'max-items': maxItems }, log);
+ assert.strictEqual(args.length, 2);
+ const [err, xml] = args;
+ assert.strictEqual(err?.message, 'InvalidArgument');
+ assert.strictEqual(err.code, 400);
+ assert.strictEqual(xml, null);
+ assert.strictEqual(listing.called, false);
+ });
+ });
+
+ it('should answer InvalidArgument to a repeated marker, with no listing', async () => {
+ const listing = sinon.spy(recordStore, 'listDestinations');
+ const [err, xml] = await list(accountAuthInfo, { marker: ['a', 'b'] }, log);
+ assert.strictEqual(err?.message, 'InvalidArgument');
+ assert.strictEqual(xml, null);
+ assert.strictEqual(listing.called, false);
+ });
+
+ it('should pass a record store listing error through', async () => {
+ sinon.stub(recordStore, 'listDestinations').callsArgWith(3, errors.InternalError);
+ const [err, xml] = await list(accountAuthInfo, {}, log);
+ assert.strictEqual(err?.message, 'InternalError');
+ assert.strictEqual(xml, null);
+ });
+
+ it('should answer InternalError when a listed record cannot be answered', async () => {
+ const record = {
+ Name: 'orders',
+ Arn: `arn:scality:bucketnotif::${accountId}:orders`,
+ AccountId: accountId,
+ Type: 'kafka',
+ Auth: { Type: 'none' },
+ CreatedAt: '2026-11-10T09:15:00Z',
+ UpdatedAt: '2026-11-10T09:15:00Z',
+ };
+ sinon
+ .stub(recordStore, 'listDestinations')
+ .callsArgWith(3, null, { destinations: [{ name: 'orders', record }], isTruncated: false });
+ const [err, xml] = await list(accountAuthInfo, {}, log);
+ assert.strictEqual(err?.message, 'InternalError');
+ assert.strictEqual(xml, null);
+ });
+
+ it('should log the account and the counts, never a name or a record', async () => {
+ await createAll(accountAuthInfo, ['orders', 'audit'], log);
+ const spyLog = new DummyRequestLogger();
+ const levels = ['trace', 'debug', 'info', 'warn', 'error'];
+ levels.forEach(level => sinon.spy(spyLog, level));
+ const [err] = await list(accountAuthInfo, { 'max-items': '1', 'marker': 'a-marker' }, spyLog);
+ assert.ifError(err);
+ const logged = JSON.stringify(levels.flatMap(level => spyLog[level].args));
+ [...secrets, 'broker1', 'bucket-events', 'orders', 'audit', 'a-marker'].forEach(value => {
+ assert.doesNotMatch(logged, new RegExp(value));
+ });
+ assert.match(logged, new RegExp(accountId));
+ });
+});