diff --git a/ably.d.ts b/ably.d.ts index 8df7cbb0a0..cfeb56c63d 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -1434,6 +1434,157 @@ declare namespace Types { unit?: StatsIntervalGranularity; } + /** + * Contains information about the results of a batch operation. + */ + interface BatchResult { + /** + * The number of successful operations in the request. + */ + successCount: number; + /** + * The number of unsuccessful operations in the request. + */ + failureCount: number; + /** + * An array of results for the batch operation. + */ + results: T[]; + } + + /** + * Describes the messages that should be published by a batch publish operation, and the channels to which they should be published. + */ + interface BatchPublishSpec { + /** + * The names of the channels to publish the `messages` to. + */ + channels: string[]; + /** + * An array of {@link Message} objects. + */ + messages: Message[]; + } + + /** + * Contains information about the result of successful publishes to a channel requested by a single {@link Types.BatchPublishSpec}. + */ + interface BatchPublishSuccessResult { + /** + * The name of the channel the message(s) was published to. + */ + channel: string; + /** + * A unique ID prefixed to the {@link Message.id} of each published message. + */ + messageId: string; + } + + /** + * Contains information about the result of unsuccessful publishes to a channel requested by a single {@link Types.BatchPublishSpec}. + */ + interface BatchPublishFailureResult { + /** + * The name of the channel the message(s) failed to be published to. + */ + channel: string; + /** + * Describes the reason for which the message(s) failed to publish to the channel as an {@link ErrorInfo} object. + */ + error: ErrorInfo; + } + + /** + * Contains information about the result of a successful batch presence request for a single channel. + */ + interface BatchPresenceSuccessResult { + /** + * The channel name the presence state was retrieved for. + */ + channel: string; + /** + * An array of {@link PresenceMessage}s describing members present on the channel. + */ + presence: PresenceMessage[]; + } + + /** + * Contains information about the result of an unsuccessful batch presence request for a single channel. + */ + interface BatchPresenceFailureResult { + /** + * The channel name the presence state failed to be retrieved for. + */ + channel: string; + /** + * Describes the reason for which presence state could not be retrieved for the channel as an {@link ErrorInfo} object. + */ + error: ErrorInfo; + } + + /** + * The `TokenRevocationOptions` interface describes the additional options accepted by the following methods: + * + * - {@link AuthCallbacks.revokeTokens} + * - {@link AuthPromise.revokeTokens} + */ + interface TokenRevocationOptions { + /** + * A Unix timestamp in milliseconds where only tokens issued before this time are revoked. The default is the current time. Requests with an `issuedBefore` in the future, or more than an hour in the past, will be rejected. + */ + issuedBefore?: number; + /** + * If true, permits a token renewal cycle to take place without needing established connections to be dropped, by postponing enforcement to 30 seconds in the future, and sending any existing connections a hint to obtain (and upgrade the connection to use) a new token. The default is `false`, meaning that the effect is near-immediate. + */ + allowReauthMargin?: boolean; + } + + /** + * Describes which tokens should be affected by a token revocation request. + */ + interface TokenRevocationTargetSpecifier { + /** + * The type of token revocation target specifier. Valid values include `clientId`, `revocationKey` and `channel`. + */ + type: string; + /** + * The value of the token revocation target specifier. + */ + value: string; + } + + /** + * Contains information about the result of a successful token revocation request for a single target specifier. + */ + interface TokenRevocationSuccessResult { + /** + * The target specifier. + */ + target: string; + /** + * The time at which the token revocation will take effect, as a Unix timestamp in milliseconds. + */ + appliesAt: number; + /** + * A Unix timestamp in milliseconds. Only tokens issued earlier than this time will be revoked. + */ + issuedBefore: number; + } + + /** + * Contains information about the result of an unsuccessful token revocation request for a single target specifier. + */ + interface TokenRevocationFailureResult { + /** + * The target specifier. + */ + target: string; + /** + * Describes the reason for which token revocation failed for the given `target` as an {@link ErrorInfo} object. + */ + error: ErrorInfo; + } + // Common Listeners /** * A standard callback format used in most areas of the callback API. @@ -1707,6 +1858,36 @@ declare namespace Types { * @param callback - A function which, upon success, will be called with the time as milliseconds since the Unix epoch. Upon failure, the function will be called with information about the error. */ time(callback?: Types.timeCallback): void; + /** + * Publishes a {@link Types.BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link Types.BatchPublishSpec} object. + * @param callback - A function which, upon success, will be called with a {@link Types.BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the function will be called with information about the error. + */ + batchPublish( + spec: BatchPublishSpec, + callback: StandardCallback> + ): void; + /** + * Publishes one or more {@link Types.BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link Types.BatchPublishSpec} objects. + * @param callback - A function which, upon success, will be called with an array of {@link Types.BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link Types.BatchPublishSpec}. This array is in the same order as the provided {@link Types.BatchPublishSpec} array. Upon failure, the function will be called with information about the error. + */ + batchPublish( + specs: BatchPublishSpec[], + callback: StandardCallback[]> + ): void; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link Types.PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @param callback - A function which, upon success, will be called with a {@link Types.BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the function will be called with information about the error. + */ + batchPresence( + channels: string[], + callback: StandardCallback> + ): void; /** * A {@link Types.PushCallbacks} object. */ @@ -1763,6 +1944,30 @@ declare namespace Types { * @returns A promise which, upon success, will be fulfilled with the time as milliseconds since the Unix epoch. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. */ time(): Promise; + + /** + * Publishes a {@link Types.BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link Types.BatchPublishSpec} object. + * @returns A promise which, upon success, will be fulfilled with a {@link Types.BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPublish(spec: BatchPublishSpec): Promise>; + /** + * Publishes one or more {@link Types.BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link Types.BatchPublishSpec} objects. + * @returns A promise which, upon success, will be fulfilled with an array of {@link Types.BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link Types.BatchPublishSpec}. This array is in the same order as the provided {@link Types.BatchPublishSpec} array. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPublish( + specs: BatchPublishSpec[] + ): Promise[]>; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link Types.PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @returns A promise which, upon success, will be fulfilled with a {@link Types.BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPresence(channels: string[]): Promise[]>; /** * A {@link Types.PushPromise} object. */ @@ -1848,6 +2053,36 @@ declare namespace Types { * @param callback - A function which, upon success, will be called with the time as milliseconds since the Unix epoch. Upon failure, the function will be called with information about the error. */ time(callback?: Types.timeCallback): void; + /** + * Publishes a {@link Types.BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link Types.BatchPublishSpec} object. + * @param callback - A function which, upon success, will be called with a {@link Types.BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the function will be called with information about the error. + */ + batchPublish( + spec: BatchPublishSpec, + callback: StandardCallback> + ): void; + /** + * Publishes one or more {@link Types.BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link Types.BatchPublishSpec} objects. + * @param callback - A function which, upon success, will be called with an array of {@link Types.BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link Types.BatchPublishSpec}. This array is in the same order as the provided {@link Types.BatchPublishSpec} array. Upon failure, the function will be called with information about the error. + */ + batchPublish( + specs: BatchPublishSpec[], + callback: StandardCallback[]> + ): void; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link Types.PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @param callback - A function which, upon success, will be called with a {@link Types.BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the function will be called with information about the error. + */ + batchPresence( + channels: string[], + callback: StandardCallback[]> + ): void; /** * A {@link Types.PushCallbacks} object. */ @@ -1900,6 +2135,29 @@ declare namespace Types { * @returns A promise which, upon success, will be fulfilled with the time as milliseconds since the Unix epoch. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. */ time(): Promise; + /** + * Publishes a {@link Types.BatchPublishSpec} object to one or more channels, up to a maximum of 100 channels. + * + * @param spec - A {@link Types.BatchPublishSpec} object. + * @returns A promise which, upon success, will be fulfilled with a {@link Types.BatchResult} object containing information about the result of the batch publish for each requested channel. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPublish(spec: BatchPublishSpec): Promise>; + /** + * Publishes one or more {@link Types.BatchPublishSpec} objects to one or more channels, up to a maximum of 100 channels. + * + * @param specs - An array of {@link Types.BatchPublishSpec} objects. + * @returns A promise which, upon success, will be fulfilled with an array of {@link Types.BatchResult} objects containing information about the result of the batch publish for each requested channel for each provided {@link Types.BatchPublishSpec}. This array is in the same order as the provided {@link Types.BatchPublishSpec} array. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPublish( + specs: BatchPublishSpec[] + ): Promise[]>; + /** + * Retrieves the presence state for one or more channels, up to a maximum of 100 channels. Presence state includes the `clientId` of members and their current {@link Types.PresenceAction}. + * + * @param channels - An array of one or more channel names, up to a maximum of 100 channels. + * @returns A promise which, upon success, will be fulfilled with a {@link Types.BatchResult} object containing information about the result of the batch presence request for each requested channel. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + batchPresence(channels: string[]): Promise[]>; /** * A {@link Types.PushPromise} object. */ @@ -1991,6 +2249,18 @@ declare namespace Types { * @param callback - A function which, upon success, will be called with a {@link TokenDetails} object. Upon failure, the function will be called with information about the error. */ requestToken(callback?: tokenDetailsCallback): void; + /** + * Revokes the tokens specified by the provided array of {@link TokenRevocationTargetSpecifier}s. Only tokens issued by an API key that had revocable tokens enabled before the token was issued can be revoked. See the [token revocation docs](https://ably.com/docs/core-features/authentication#token-revocation) for more information. + * + * @param specifiers - An array of {@link TokenRevocationTargetSpecifier} objects. + * @param options - A set of options which are used to modify the revocation request. + * @param callback - A function which, upon success, will be called with a {@link Types.BatchResult} containing information about the result of the token revocation request for each provided [`TokenRevocationTargetSpecifier`]{@link TokenRevocationTargetSpecifier}. Upon failure, the function will be called with information about the error. + */ + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + options?: TokenRevocationOptions, + callback?: StandardCallback> + ): void; } /** @@ -2021,6 +2291,17 @@ declare namespace Types { * @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error. */ requestToken(TokenParams?: TokenParams, authOptions?: AuthOptions): Promise; + /** + * Revokes the tokens specified by the provided array of {@link TokenRevocationTargetSpecifier}s. Only tokens issued by an API key that had revocable tokens enabled before the token was issued can be revoked. See the [token revocation docs](https://ably.com/docs/core-features/authentication#token-revocation) for more information. + * + * @param specifiers - An array of {@link TokenRevocationTargetSpecifier} objects. + * @param options - A set of options which are used to modify the revocation request. + * @returns A promise which, upon success, will be fulfilled with a {@link Types.BatchResult} containing information about the result of the token revocation request for each provided [`TokenRevocationTargetSpecifier`]{@link TokenRevocationTargetSpecifier}. Upon failure, the promise will be rejected with an {@link Types.ErrorInfo} object which explains the error. + */ + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + options?: TokenRevocationOptions + ): Promise>; } /** diff --git a/src/common/lib/client/auth.ts b/src/common/lib/client/auth.ts index b92a3668d8..5b0a855b63 100644 --- a/src/common/lib/client/auth.ts +++ b/src/common/lib/client/auth.ts @@ -14,6 +14,14 @@ import ClientOptions from '../../types/ClientOptions'; import HttpMethods from '../../constants/HttpMethods'; import HttpStatusCodes from 'common/constants/HttpStatusCodes'; import Platform from '../../platform'; +import Resource from './resource'; + +type BatchResult = API.Types.BatchResult; +type TokenRevocationTargetSpecifier = API.Types.TokenRevocationTargetSpecifier; +type TokenRevocationOptions = API.Types.TokenRevocationOptions; +type TokenRevocationSuccessResult = API.Types.TokenRevocationSuccessResult; +type TokenRevocationFailureResult = API.Types.TokenRevocationFailureResult; +type TokenRevocationResult = BatchResult; const MAX_TOKEN_LENGTH = Math.pow(2, 17); function noop() {} @@ -1054,6 +1062,76 @@ class Auth { static isTokenErr(error: IPartialErrorInfo) { return error.code && error.code >= 40140 && error.code < 40150; } + + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + options?: TokenRevocationOptions, + callback?: API.Types.StandardCallback + ): void; + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + options?: TokenRevocationOptions + ): Promise; + revokeTokens( + specifiers: TokenRevocationTargetSpecifier[], + optionsOrCallbackArg?: TokenRevocationOptions | API.Types.StandardCallback, + callbackArg?: API.Types.StandardCallback + ): void | Promise { + if (useTokenAuth(this.client.options)) { + throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401); + } + + const keyName = this.client.options.keyName!; + + let resolvedOptions: TokenRevocationOptions; + + if (typeof optionsOrCallbackArg === 'function') { + callbackArg = optionsOrCallbackArg; + resolvedOptions = {}; + } else { + resolvedOptions = optionsOrCallbackArg ?? {}; + } + + if (callbackArg === undefined) { + if (this.client.options.promises) { + return Utils.promisify(this, 'revokeTokens', [specifiers, resolvedOptions]); + } + callbackArg = noop; + } + + const callback = callbackArg; + + const requestBodyDTO = { + targets: specifiers.map((specifier) => `${specifier.type}:${specifier.value}`), + ...resolvedOptions, + }; + + const format = this.client.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + headers = Utils.defaultPostHeaders(this.client.options, format); + + if (this.client.options.headers) Utils.mixin(headers, this.client.options.headers); + + const requestBody = Utils.encodeBody(requestBodyDTO, format); + Resource.post( + this.client, + `/keys/${keyName}/revokeTokens`, + requestBody, + headers, + { newBatchResponse: 'true' }, + null, + (err, body, headers, unpacked) => { + if (err) { + // TODO remove this type assertion after fixing https://github.com/ably/ably-js/issues/1405 + callback(err as API.Types.ErrorInfo); + return; + } + + const batchResult = (unpacked ? body : Utils.decodeBody(body, format)) as TokenRevocationResult; + + callback(null, batchResult); + } + ); + } } export default Auth; diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index 1ee41d8dfc..ef7dd31dcc 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -12,10 +12,21 @@ import { ChannelOptions } from '../../types/channel'; import { PaginatedResultCallback, StandardCallback } from '../../types/utils'; import { ErrnoException, IHttp, RequestParams } from '../../types/http'; import ClientOptions, { DeprecatedClientOptions, NormalisedClientOptions } from '../../types/ClientOptions'; +import * as API from '../../../../ably'; import Platform from '../../platform'; import Message from '../types/message'; import PresenceMessage from '../types/presencemessage'; +import Resource from './resource'; + +type BatchResult = API.Types.BatchResult; +type BatchPublishSpec = API.Types.BatchPublishSpec; +type BatchPublishSuccessResult = API.Types.BatchPublishSuccessResult; +type BatchPublishFailureResult = API.Types.BatchPublishFailureResult; +type BatchPublishResult = BatchResult; +type BatchPresenceSuccessResult = API.Types.BatchPresenceSuccessResult; +type BatchPresenceFailureResult = API.Types.BatchPresenceFailureResult; +type BatchPresenceResult = BatchResult; const noop = function () {}; class Rest { @@ -228,6 +239,110 @@ class Rest { } } + batchPublish( + specOrSpecs: T, + callback: API.Types.StandardCallback + ): void; + batchPublish( + specOrSpecs: T + ): Promise; + batchPublish( + specOrSpecs: T, + callbackArg?: API.Types.StandardCallback + ): void | Promise { + if (callbackArg === undefined) { + if (this.options.promises) { + return Utils.promisify(this, 'batchPublish', [specOrSpecs]); + } + callbackArg = noop; + } + + const callback = callbackArg; + + let requestBodyDTO: BatchPublishSpec[]; + let singleSpecMode: boolean; + if (Utils.isArray(specOrSpecs)) { + requestBodyDTO = specOrSpecs; + singleSpecMode = false; + } else { + requestBodyDTO = [specOrSpecs]; + singleSpecMode = true; + } + + const format = this.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + headers = Utils.defaultPostHeaders(this.options, format); + + if (this.options.headers) Utils.mixin(headers, this.options.headers); + + const requestBody = Utils.encodeBody(requestBodyDTO, format); + Resource.post( + this, + '/messages', + requestBody, + headers, + { newBatchResponse: 'true' }, + null, + (err, body, headers, unpacked) => { + if (err) { + // TODO remove this type assertion after fixing https://github.com/ably/ably-js/issues/1405 + callback(err as API.Types.ErrorInfo); + return; + } + + const batchResults = (unpacked ? body : Utils.decodeBody(body, format)) as BatchPublishResult[]; + + // I don't love the below type assertions for `callback` but not sure how to avoid them + if (singleSpecMode) { + (callback as API.Types.StandardCallback)(null, batchResults[0]); + } else { + (callback as API.Types.StandardCallback)(null, batchResults); + } + } + ); + } + + batchPresence(channels: string[], callback: API.Types.StandardCallback): void; + batchPresence(channels: string[]): Promise; + batchPresence( + channels: string[], + callbackArg?: API.Types.StandardCallback + ): void | Promise { + if (callbackArg === undefined) { + if (this.options.promises) { + return Utils.promisify(this, 'batchPresence', [channels]); + } + callbackArg = noop; + } + + const callback = callbackArg; + + const format = this.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json, + headers = Utils.defaultPostHeaders(this.options, format); + + if (this.options.headers) Utils.mixin(headers, this.options.headers); + + const channelsParam = channels.join(','); + + Resource.get( + this, + '/presence', + headers, + { newBatchResponse: 'true', channels: channelsParam }, + null, + (err, body, headers, unpacked) => { + if (err) { + // TODO remove this type assertion after fixing https://github.com/ably/ably-js/issues/1405 + callback(err as API.Types.ErrorInfo); + return; + } + + const batchResult = (unpacked ? body : Utils.decodeBody(body, format)) as BatchPresenceResult; + + callback(null, batchResult); + } + ); + } + setLog(logOptions: LoggerOptions): void { Logger.setLog(logOptions.level, logOptions.handler); } diff --git a/test/common/modules/shared_helper.js b/test/common/modules/shared_helper.js index e226dc5f07..d6b6812910 100644 --- a/test/common/modules/shared_helper.js +++ b/test/common/modules/shared_helper.js @@ -212,6 +212,10 @@ define([ return res; }; + function randomString() { + return Math.random().toString().slice(2); + } + return (module.exports = { setupApp: testAppModule.setup, tearDownApp: testAppModule.tearDown, @@ -242,5 +246,6 @@ define([ unroutableAddress: unroutableAddress, arrFind: arrFind, arrFilter: arrFilter, + randomString: randomString, }); }); diff --git a/test/realtime/channel.test.js b/test/realtime/channel.test.js index 886548bdf9..252a9b1ef6 100644 --- a/test/realtime/channel.test.js +++ b/test/realtime/channel.test.js @@ -9,11 +9,7 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async var monitorConnection = helper.monitorConnection; var createPM = Ably.Realtime.ProtocolMessage.fromDeserialized; var testOnAllTransports = helper.testOnAllTransports; - - /* Helpers */ - function randomString() { - return Math.random().toString().slice(2); - } + var randomString = helper.randomString; function checkCanSubscribe(channel, testChannel) { return function (callback) { diff --git a/test/rest/batch.test.js b/test/rest/batch.test.js new file mode 100644 index 0000000000..b4f747311f --- /dev/null +++ b/test/rest/batch.test.js @@ -0,0 +1,867 @@ +'use strict'; + +// NOTE: All of the Promise-related tests in this file are intentionally a copy of the callback versions. This will allow us to simply remove the callback versions when merging this functionality into the integration/v2 branch (https://github.com/ably/ably-js/issues/1411). + +define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async, chai) { + var expect = chai.expect; + var closeAndFinish = helper.closeAndFinish; + var randomString = helper.randomString; + + describe('rest/batchPublish', function () { + this.timeout(60 * 1000); + + before(function (done) { + helper.setupApp(function (err) { + if (err) { + done(err); + } + done(); + }); + }); + + describe('when invoked with an array of specs', function () { + it('performs a batch publish and returns an array of results', function (done) { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + key: testApp.keys[2].keyStr /* we use this key so that some publishes fail due to capabilities */, + }); + const verificationRest = helper.AblyRest(); + + const specs = [ + { + channels: [ + 'channel0' /* key allows publishing to this channel */, + 'channel3' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message1' }, { data: 'message2' }], + }, + { + channels: [ + 'channel4' /* key allows publishing to this channel */, + 'channel5' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message3' }, { data: 'message4' }], + }, + ]; + + async.series( + [ + // First, we perform the batch publish request... + function (cb) { + rest.batchPublish(specs, function (err, batchResults) { + if (err) { + cb(err); + return; + } + + try { + expect(batchResults).to.have.lengthOf(specs.length); + + expect(batchResults[0].successCount).to.equal(1); + expect(batchResults[0].failureCount).to.equal(1); + + // Check the results of first BatchPublishSpec + + expect(batchResults[0].results).to.have.lengthOf(2); + + expect(batchResults[0].results[0].channel).to.equal('channel0'); + expect(batchResults[0].results[0].messageId).to.include(':0'); + expect('error' in batchResults[0].results[0]).to.be.false; + + expect(batchResults[0].results[1].channel).to.equal('channel3'); + expect('messageId' in batchResults[0].results[1]).to.be.false; + expect(batchResults[0].results[1].error.statusCode).to.equal(401); + + // Check the results of second BatchPublishSpec + + expect(batchResults[1].results).to.have.lengthOf(2); + + expect(batchResults[1].results[0].channel).to.equal('channel4'); + expect(batchResults[1].results[0].messageId).to.include(':0'); + expect('error' in batchResults[1].results[0]).to.be.false; + + expect(batchResults[1].results[1].channel).to.equal('channel5'); + expect('messageId' in batchResults[1].results[1]).to.be.false; + expect(batchResults[1].results[1].error.statusCode).to.equal(401); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + function (cb) { + // ...and now we use channel history to check that the expected messages have been published. + async.parallel( + [ + function (cb) { + const channel0 = verificationRest.channels.get('channel0'); + channel0.history({ limit: 2 }, function (err, result) { + if (err) { + cb(err); + return; + } + + const data = new Set([result.items[0].data, result.items[1].data]); + + try { + expect(data).to.deep.equal(new Set(['message1', 'message2'])); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + function (cb) { + const channel4 = verificationRest.channels.get('channel4'); + channel4.history({ limit: 2 }, function (err, result) { + if (err) { + cb(err); + return; + } + + const data = new Set([result.items[0].data, result.items[1].data]); + try { + expect(data).to.deep.equal(new Set(['message3', 'message4'])); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + ], + cb + ); + }, + ], + done + ); + }); + }); + + describe('when invoked with a single spec', function () { + it('performs a batch publish and returns a single result', function (done) { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + key: testApp.keys[2].keyStr /* we use this key so that some publishes fail due to capabilities */, + }); + const verificationRest = helper.AblyRest(); + + const spec = { + channels: [ + 'channel0' /* key allows publishing to this channel */, + 'channel3' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message1' }, { data: 'message2' }], + }; + + async.series( + [ + // First, we perform the batch publish request... + function (cb) { + rest.batchPublish(spec, function (err, batchResult) { + if (err) { + cb(err); + return; + } + + try { + expect(batchResult.successCount).to.equal(1); + expect(batchResult.failureCount).to.equal(1); + + expect(batchResult.results).to.have.lengthOf(2); + + expect(batchResult.results[0].channel).to.equal('channel0'); + expect(batchResult.results[0].messageId).to.include(':0'); + expect('error' in batchResult.results[0]).to.be.false; + + expect(batchResult.results[1].channel).to.equal('channel3'); + expect('messageId' in batchResult.results[1]).to.be.false; + expect(batchResult.results[1].error.statusCode).to.equal(401); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + function (cb) { + // ...and now we use channel history to check that the expected messages have been published. + const channel0 = verificationRest.channels.get('channel0'); + channel0.history({ limit: 2 }, function (err, result) { + if (err) { + cb(err); + return; + } + + const data = new Set([result.items[0].data, result.items[1].data]); + try { + expect(data).to.deep.equal(new Set(['message1', 'message2'])); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + ], + done + ); + }); + }); + + if (typeof Promise !== 'undefined') { + describe('using promises', function () { + describe('when invoked with an array of specs', function () { + it('performs a batch publish and returns an array of results', async function () { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + promises: true, + key: testApp.keys[2].keyStr /* we use this key so that some publishes fail due to capabilities */, + }); + + const specs = [ + { + channels: [ + 'channel0' /* key allows publishing to this channel */, + 'channel3' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message1' }, { data: 'message2' }], + }, + { + channels: [ + 'channel4' /* key allows publishing to this channel */, + 'channel5' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message3' }, { data: 'message4' }], + }, + ]; + + // First, we perform the batch publish request... + const batchResults = await rest.batchPublish(specs); + + expect(batchResults).to.have.lengthOf(specs.length); + + expect(batchResults[0].successCount).to.equal(1); + expect(batchResults[0].failureCount).to.equal(1); + + // Check the results of first BatchPublishSpec + + expect(batchResults[0].results).to.have.lengthOf(2); + + expect(batchResults[0].results[0].channel).to.equal('channel0'); + expect(batchResults[0].results[0].messageId).to.include(':0'); + expect('error' in batchResults[0].results[0]).to.be.false; + + expect(batchResults[0].results[1].channel).to.equal('channel3'); + expect('messageId' in batchResults[0].results[1]).to.be.false; + expect(batchResults[0].results[1].error.statusCode).to.equal(401); + + // Check the results of second BatchPublishSpec + + expect(batchResults[1].results).to.have.lengthOf(2); + + expect(batchResults[1].results[0].channel).to.equal('channel4'); + expect(batchResults[1].results[0].messageId).to.include(':0'); + expect('error' in batchResults[1].results[0]).to.be.false; + + expect(batchResults[1].results[1].channel).to.equal('channel5'); + expect('messageId' in batchResults[1].results[1]).to.be.false; + expect(batchResults[1].results[1].error.statusCode).to.equal(401); + + // ...and now we use channel history to check that the expected messages have been published. + const verificationRest = helper.AblyRest({ promises: true }); + + const channel0 = verificationRest.channels.get('channel0'); + const channel0HistoryPromise = channel0.history({ limit: 2 }); + + const channel4 = verificationRest.channels.get('channel4'); + const channel4HistoryPromise = channel4.history({ limit: 2 }); + + const [channel0History, channel4History] = await Promise.all([ + channel0HistoryPromise, + channel4HistoryPromise, + ]); + + const channel0HistoryData = new Set([channel0History.items[0].data, channel0History.items[1].data]); + expect(channel0HistoryData).to.deep.equal(new Set(['message1', 'message2'])); + + const channel4HistoryData = new Set([channel4History.items[0].data, channel4History.items[1].data]); + expect(channel4HistoryData).to.deep.equal(new Set(['message3', 'message4'])); + }); + }); + + describe('when invoked with a single spec', function () { + it('performs a batch publish and returns a single result', async function () { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + promises: true, + key: testApp.keys[2].keyStr /* we use this key so that some publishes fail due to capabilities */, + }); + + const spec = { + channels: [ + 'channel0' /* key allows publishing to this channel */, + 'channel3' /* key does not allow publishing to this channel */, + ], + messages: [{ data: 'message1' }, { data: 'message2' }], + }; + + // First, we perform the batch publish request... + const batchResult = await rest.batchPublish(spec); + + expect(batchResult.successCount).to.equal(1); + expect(batchResult.failureCount).to.equal(1); + + expect(batchResult.results).to.have.lengthOf(2); + + expect(batchResult.results[0].channel).to.equal('channel0'); + expect(batchResult.results[0].messageId).to.include(':0'); + expect('error' in batchResult.results[0]).to.be.false; + + expect(batchResult.results[1].channel).to.equal('channel3'); + expect('messageId' in batchResult.results[1]).to.be.false; + expect(batchResult.results[1].error.statusCode).to.equal(401); + + // ...and now we use channel history to check that the expected messages have been published. + const verificationRest = helper.AblyRest({ promises: true }); + const channel0 = verificationRest.channels.get('channel0'); + const channel0History = await channel0.history({ limit: 2 }); + + const channel0HistoryData = new Set([channel0History.items[0].data, channel0History.items[1].data]); + expect(channel0HistoryData).to.deep.equal(new Set(['message1', 'message2'])); + }); + }); + }); + } + }); + + describe('rest/batchPresence', function () { + this.timeout(60 * 1000); + + before(function (done) { + helper.setupApp(function (err) { + if (err) { + done(err); + } + done(); + }); + }); + + it('performs a batch presence fetch and returns a result', function (done) { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + key: testApp.keys[2].keyStr /* we use this key so that some presence fetches fail due to capabilities */, + }); + + const presenceEnterRealtime = helper.AblyRealtime( + { + clientId: 'batchPresenceTest', + } /* note that the key used here has no capability limitations, so that we can use this instance to enter presence below */ + ); + + const channelNames = [ + 'channel0' /* key does not allow presence on this channel */, + 'channel4' /* key allows presence on this channel */, + ]; + + async.series( + [ + // First, we enter presence on two channels... + function (cb) { + presenceEnterRealtime.channels.get('channel0').presence.enter(cb); + }, + function (cb) { + presenceEnterRealtime.channels.get('channel4').presence.enter(cb); + }, + // ...and now we perform the batch presence request. + function (cb) { + rest.batchPresence(channelNames, function (err, batchResult) { + if (err) { + cb(err); + } + + try { + expect(batchResult.successCount).to.equal(1); + expect(batchResult.failureCount).to.equal(1); + + // Check that the channel0 presence fetch request fails (due to key’s capabilities, as mentioned above) + + expect(batchResult.results[0].channel).to.equal('channel0'); + expect('presence' in batchResult.results[0]).to.be.false; + expect(batchResult.results[0].error.statusCode).to.equal(401); + + // Check that the channel4 presence fetch request reflects the presence enter performed above + + expect(batchResult.results[1].channel).to.equal('channel4'); + expect(batchResult.results[1].presence).to.have.lengthOf(1); + expect(batchResult.results[1].presence[0].clientId).to.equal('batchPresenceTest'); + expect('error' in batchResult.results[1]).to.be.false; + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + function (cb) { + closeAndFinish(cb, presenceEnterRealtime); + }, + ], + done + ); + }); + + if (typeof Promise !== 'undefined') { + describe('using promises', function () { + it('performs a batch presence fetch and returns a result', async function () { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + promises: true, + key: testApp.keys[2].keyStr /* we use this key so that some presence fetches fail due to capabilities */, + }); + + const presenceEnterRealtime = helper.AblyRealtime({ + promises: true, + clientId: + 'batchPresenceTest' /* note that the key used here has no capability limitations, so that we can use this instance to enter presence below */, + }); + + const channelNames = [ + 'channel0' /* key does not allow presence on this channel */, + 'channel4' /* key allows presence on this channel */, + ]; + + // First, we enter presence on two channels... + await presenceEnterRealtime.channels.get('channel0').presence.enter(); + await presenceEnterRealtime.channels.get('channel4').presence.enter(); + + // ...and now we perform the batch presence request. + const batchResult = await rest.batchPresence(channelNames); + + expect(batchResult.successCount).to.equal(1); + expect(batchResult.failureCount).to.equal(1); + + // Check that the channel0 presence fetch request fails (due to key’s capabilities, as mentioned above) + + expect(batchResult.results[0].channel).to.equal('channel0'); + expect('presence' in batchResult.results[0]).to.be.false; + expect(batchResult.results[0].error.statusCode).to.equal(401); + + // Check that the channel4 presence fetch request reflects the presence enter performed above + + expect(batchResult.results[1].channel).to.equal('channel4'); + expect(batchResult.results[1].presence).to.have.lengthOf(1); + expect(batchResult.results[1].presence[0].clientId).to.equal('batchPresenceTest'); + expect('error' in batchResult.results[1]).to.be.false; + + await new Promise((resolve, reject) => { + closeAndFinish((err) => { + err ? reject(err) : resolve(); + }, presenceEnterRealtime); + }); + }); + }); + } + }); + + describe('rest/revokeTokens', function () { + this.timeout(60 * 1000); + + before(function (done) { + helper.setupApp(function (err) { + if (err) { + done(err); + } + done(); + }); + }); + + it('revokes tokens matching the given specifiers', function (done) { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */, + }); + + const clientId1 = `clientId1-${randomString()}`; + const clientId2 = `clientId2-${randomString()}`; + + let clientId1TokenDetails; + let clientId2TokenDetails; + + let clientId1Realtime; + let clientId2Realtime; + + // These (result, callback) pairings are a dance to simulate a Promise (specificially the fact that the order of the { resolve, then } operations doesn’t matter); see the promise-based version of this test + let clientId1RealtimeDisconnectedStateChange; + let onClientId1RealtimeDisconnected; + let clientId2RealtimeDisconnectedStateChange; + let onClientId2RealtimeDisconnected; + + async.series( + [ + function (cb) { + // First, we fetch tokens for a couple of different clientIds... + async.parallel( + [ + function (cb) { + rest.auth.requestToken({ clientId: clientId1 }, function (err, tokenDetails) { + if (err) { + cb(err); + return; + } + + clientId1TokenDetails = tokenDetails; + cb(); + }); + }, + function (cb) { + rest.auth.requestToken({ clientId: clientId2 }, function (err, tokenDetails) { + if (err) { + cb(err); + return; + } + + clientId2TokenDetails = tokenDetails; + cb(); + }); + }, + ], + cb + ); + }, + function (cb) { + // ...then, we set up Realtime instances that use these tokens and wait for them to become CONNECTED... + async.parallel( + [ + function (cb) { + clientId1Realtime = helper.AblyRealtime({ token: clientId1TokenDetails }); + clientId1Realtime.connection.once('connected', function () { + cb(); + }); + }, + function (cb) { + clientId2Realtime = helper.AblyRealtime({ token: clientId2TokenDetails }); + clientId2Realtime.connection.once('connected', function () { + cb(); + }); + }, + ], + cb + ); + }, + function (cb) { + // ...then, we set up listeners that will record the state change when these Realtime instances become DISCONNECTED (we need to set up these listeners here, before performing the revocation request, else we might miss the DISCONNECTED state changes that the token revocation provokes, and end up only seeing the subsequent RSA4a2-induced FAILED state change, which due to https://github.com/ably/ably-js/issues/1409 does not expose the 40141 "token revoked" error code)... + // + // Note: + // + // We use Realtime instances for verifying the side effects of a token revocation, as opposed to, say, trying to perform a REST request, because the nature of the Ably service is that token verification may take a small delay to become active, and so there's no guarantee that a REST request peformed immediately after a revocation request would fail. See discussion at https://ably-real-time.slack.com/archives/C030C5YLY/p1690322740850269?thread_ts=1690315022.372729&cid=C030C5YLY. + + clientId1Realtime.connection.once('disconnected', function (stateChange) { + clientId1RealtimeDisconnectedStateChange = stateChange; + if (onClientId1RealtimeDisconnected) { + onClientId1RealtimeDisconnected(); + } + }); + + clientId2Realtime.connection.once('disconnected', function (stateChange) { + clientId2RealtimeDisconnectedStateChange = stateChange; + if (onClientId2RealtimeDisconnected) { + onClientId2RealtimeDisconnected(); + } + }); + + cb(); + }, + function (cb) { + // ...then, we revoke all tokens for these clientIds... + + const specifiers = [ + { type: 'clientId', value: clientId1 }, + { type: 'clientId', value: clientId2 }, + { type: 'invalidType', value: 'abc' }, // we include an invalid specifier type to provoke a non-zero failureCount + ]; + + rest.auth.revokeTokens(specifiers, function (err, result) { + if (err) { + cb(err); + return; + } + + try { + // ...and check the response from the revocation request... + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.lengthOf(3); + + expect(result.results[0].target).to.equal(`clientId:${clientId1}`); + expect(typeof result.results[0].issuedBefore).to.equal('number'); + expect(typeof result.results[0].appliesAt).to.equal('number'); + expect('error' in result.results[0]).to.be.false; + + expect(result.results[1].target).to.equal(`clientId:${clientId2}`); + expect(typeof result.results[1].issuedBefore).to.equal('number'); + expect(typeof result.results[1].appliesAt).to.equal('number'); + expect('error' in result.results[1]).to.be.false; + + expect(result.results[2].target).to.equal('invalidType:abc'); + expect(result.results[2].error.statusCode).to.equal(400); + } catch (err) { + cb(err); + return; + } + + cb(); + }); + }, + + // ...and then, we check that the Realtime instances transition to the DISCONNECTED state due to a "token revoked" (40141) error. + function (cb) { + async.parallel( + [ + function (cb) { + onClientId1RealtimeDisconnected = function () { + try { + expect(clientId1RealtimeDisconnectedStateChange.reason.code).to.equal(40141 /* token revoked */); + } catch (err) { + cb(err); + return; + } + cb(); + }; + if (clientId1RealtimeDisconnectedStateChange) { + onClientId1RealtimeDisconnected(); + } + }, + function (cb) { + onClientId2RealtimeDisconnected = function () { + try { + expect(clientId2RealtimeDisconnectedStateChange.reason.code).to.equal(40141 /* token revoked */); + } catch (err) { + cb(err); + return; + } + cb(); + }; + if (clientId2RealtimeDisconnectedStateChange) { + onClientId2RealtimeDisconnected(); + } + }, + ], + cb + ); + }, + function (cb) { + async.parallel( + [ + function (cb) { + closeAndFinish(cb, clientId1Realtime); + }, + function (cb) { + closeAndFinish(cb, clientId2Realtime); + }, + ], + cb + ); + }, + ], + done + ); + }); + + it('accepts optional issuedBefore and allowReauthMargin parameters', function (done) { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */, + }); + + const clientId = `clientId-${randomString()}`; + + let serverTimeAtStartOfTest; + + async.series( + [ + function (cb) { + rest.time(function (err, time) { + if (err) { + cb(err); + return; + } + serverTimeAtStartOfTest = time; + cb(); + }); + }, + function (cb) { + const issuedBefore = serverTimeAtStartOfTest - 20 * 60 * 1000; // i.e. ~20 minutes ago (arbitrarily chosen) + + rest.auth.revokeTokens( + [{ type: 'clientId', value: clientId }], + { issuedBefore, allowReauthMargin: true }, + function (err, result) { + if (err) { + cb(err); + return; + } + + try { + expect(result.results[0].issuedBefore).to.equal(issuedBefore); + + // Verify the expected side effect of allowReauthMargin, which is to delay the revocation by 30 seconds + const serverTimeThirtySecondsAfterStartOfTest = serverTimeAtStartOfTest + 30 * 1000; + expect(result.results[0].appliesAt).to.be.greaterThan(serverTimeThirtySecondsAfterStartOfTest); + } catch (err) { + cb(err); + return; + } + + cb(); + } + ); + }, + ], + done + ); + }); + + it('throws an error when using token auth', function () { + const rest = helper.AblyRest({ + useTokenAuth: true, + }); + + let verifiedError = false; + try { + rest.auth.revokeTokens([{ type: 'clientId', value: 'clientId1' }], function () {}); + } catch (err) { + expect(err.statusCode).to.equal(401); + expect(err.code).to.equal(40162); + verifiedError = true; + } + + expect(verifiedError).to.be.true; + }); + + if (typeof Promise !== 'undefined') { + describe('using promises', function () { + it('revokes tokens matching the given specifiers', async function () { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + promises: true, + key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */, + }); + + const clientId1 = `clientId1-${randomString()}`; + const clientId2 = `clientId2-${randomString()}`; + + // First, we fetch tokens for a couple of different clientIds... + const [clientId1TokenDetails, clientId2TokenDetails] = await Promise.all([ + rest.auth.requestToken({ clientId: clientId1 }), + rest.auth.requestToken({ clientId: clientId2 }), + ]); + + // ...then, we set up Realtime instances that use these tokens and wait for them to become CONNECTED... + const clientId1Realtime = helper.AblyRealtime({ + promises: true, + token: clientId1TokenDetails, + }); + const clientId2Realtime = helper.AblyRealtime({ + promises: true, + token: clientId2TokenDetails, + }); + + await Promise.all([ + clientId1Realtime.connection.once('connected'), + clientId2Realtime.connection.once('connected'), + ]); + + // ...then, we set up listeners that will record the state change when these Realtime instances become DISCONNECTED (we need to set up these listeners here, before performing the revocation request, else we might miss the DISCONNECTED state changes that the token revocation provokes, and end up only seeing the subsequent RSA4a2-induced FAILED state change, which due to https://github.com/ably/ably-js/issues/1409 does not expose the 40141 "token revoked" error code)... + // + // Note: + // + // We use Realtime instances for verifying the side effects of a token revocation, as opposed to, say, trying to perform a REST request, because the nature of the Ably service is that token revocation may take a small delay to become active, and so there's no guarantee that a REST request peformed immediately after a revocation request would fail. See discussion at https://ably-real-time.slack.com/archives/C030C5YLY/p1690322740850269?thread_ts=1690315022.372729&cid=C030C5YLY. + const clientId1RealtimeDisconnectedStateChangePromise = clientId1Realtime.connection.once('disconnected'); + const clientId2RealtimeDisconnectedStateChangePromise = clientId2Realtime.connection.once('disconnected'); + + // ...then, we revoke all tokens for these clientIds... + + const specifiers = [ + { type: 'clientId', value: clientId1 }, + { type: 'clientId', value: clientId2 }, + { type: 'invalidType', value: 'abc' }, // we include an invalid specifier type to provoke a non-zero failureCount + ]; + + const result = await rest.auth.revokeTokens(specifiers); + + // ...and check the response from the revocation request... + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.lengthOf(3); + + expect(result.results[0].target).to.equal(`clientId:${clientId1}`); + expect(typeof result.results[0].issuedBefore).to.equal('number'); + expect(typeof result.results[0].appliesAt).to.equal('number'); + expect('error' in result.results[0]).to.be.false; + + expect(result.results[1].target).to.equal(`clientId:${clientId2}`); + expect(typeof result.results[1].issuedBefore).to.equal('number'); + expect(typeof result.results[1].appliesAt).to.equal('number'); + expect('error' in result.results[1]).to.be.false; + + expect(result.results[2].target).to.equal('invalidType:abc'); + expect(result.results[2].error.statusCode).to.equal(400); + + // ...and then, we check that the Realtime instances transition to the DISCONNECTED state due to a "token revoked" (40141) error. + const [clientId1RealtimeDisconnectedStateChange, clientId2RealtimeDisconnectedStateChange] = + await Promise.all([ + clientId1RealtimeDisconnectedStateChangePromise, + clientId2RealtimeDisconnectedStateChangePromise, + ]); + + expect(clientId1RealtimeDisconnectedStateChange.reason.code).to.equal(40141 /* token revoked */); + expect(clientId2RealtimeDisconnectedStateChange.reason.code).to.equal(40141 /* token revoked */); + + await Promise.all( + [clientId1Realtime, clientId2Realtime].map((realtime) => { + new Promise((resolve, reject) => { + closeAndFinish((err) => { + err ? reject(err) : resolve(); + }, realtime); + }); + }) + ); + }); + + it('accepts optional issuedBefore and allowReauthMargin parameters', async function () { + const testApp = helper.getTestApp(); + const rest = helper.AblyRest({ + promises: true, + key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */, + }); + + const clientId = `clientId-${randomString()}`; + + const serverTimeAtStartOfTest = await rest.time(); + const issuedBefore = serverTimeAtStartOfTest - 20 * 60 * 1000; // i.e. ~20 minutes ago (arbitrarily chosen) + + const result = await rest.auth.revokeTokens([{ type: 'clientId', value: clientId }], { + issuedBefore, + allowReauthMargin: true, + }); + + expect(result.results[0].issuedBefore).to.equal(issuedBefore); + + // Verify the expected side effect of allowReauthMargin, which is to delay the revocation by 30 seconds + const serverTimeThirtySecondsAfterStartOfTest = serverTimeAtStartOfTest + 30 * 1000; + expect(result.results[0].appliesAt).to.be.greaterThan(serverTimeThirtySecondsAfterStartOfTest); + }); + }); + } + }); +}); diff --git a/test/support/browser_file_list.js b/test/support/browser_file_list.js index 31369d1f3a..6175faaf54 100644 --- a/test/support/browser_file_list.js +++ b/test/support/browser_file_list.js @@ -70,4 +70,5 @@ window.__testFiles__.files = { 'test/browser/simple.test.js': true, 'test/browser/http.test.js': true, 'test/rest/status.test.js': true, + 'test/rest/batch.test.js': true, };