diff --git a/device/core/devdoc/utils_requirement.md b/device/core/devdoc/utils_requirement.md index 203229d28..5fffe6b92 100644 --- a/device/core/devdoc/utils_requirement.md +++ b/device/core/devdoc/utils_requirement.md @@ -13,4 +13,10 @@ getUserAgentString returns a user agent string that can be sent to the service. **SRS_NODE_DEVICE_UTILS_18_001: [** `getUserAgentString` shall call `getAgentPlatformString` to get the platform string. **]** -**SRS_NODE_DEVICE_UTILS_18_002: [** `getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'. **]** +**SRS_NODE_DEVICE_UTILS_18_002: [** `getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'. **]** + +**SRS_NODE_DEVICE_UTILS_41_001: [** `getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg. **]** + +**SRS_NODE_DEVICE_UTILS_41_002: [** `getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/()'. **]** + +**SRS_NODE_DEVICE_UTILS_41_003: [** `getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`. **]** diff --git a/device/core/src/interfaces.ts b/device/core/src/interfaces.ts index 892c55215..c8554aeb5 100644 --- a/device/core/src/interfaces.ts +++ b/device/core/src/interfaces.ts @@ -118,17 +118,23 @@ export interface DeviceClientOptions extends X509 { ca?: string; /** - * Optional object with options specific to the Mqtt transport + * Custom user defined information to be appended to existing User Agent information. The User Agent Identification information + * is used predominantly by Microsoft internally for identifying metadata related to Device Client usage for Azure IoT. */ - mqtt?: MqttTransportOptions; + productInfo?: string; /** - * Optional object with options specific to the Mqtt transport + * Optional object with options specific to the MQTT transport */ - http?: HttpTransportOptions; + mqtt?: MqttTransportOptions; /** - * Optional object with options specific to the Amqp transport + * Optional object with options specific to the HTTP transport + */ + http?: HttpTransportOptions; + + /** + * Optional object with options specific to the AMQP transport */ amqp?: AmqpTransportOptions; } diff --git a/device/core/src/module_client.ts b/device/core/src/module_client.ts index 136ed0788..42844ff43 100644 --- a/device/core/src/module_client.ts +++ b/device/core/src/module_client.ts @@ -234,7 +234,9 @@ export class ModuleClient extends InternalClient { setOptions(options: DeviceClientOptions, done?: Callback): Promise | void { return callbackToPromise((_callback) => { /*Codes_SRS_NODE_MODULE_CLIENT_16_098: [The `setOptions` method shall call the `setOptions` method with the `options` argument on the `MethodClient` object of the `ModuleClient`.]*/ - this._methodClient.setOptions(options); + if (this._methodClient) { + this._methodClient.setOptions(options); + } /*Codes_SRS_NODE_MODULE_CLIENT_16_042: [The `setOptions` method shall throw a `ReferenceError` if the options object is falsy.]*/ /*Codes_SRS_NODE_MODULE_CLIENT_16_043: [The `_callback` callback shall be invoked with no parameters when it has successfully finished setting the client and/or transport options.]*/ /*Codes_SRS_NODE_MODULE_CLIENT_16_044: [The `_callback` callback shall be invoked with a standard javascript `Error` object and no result object if the client could not be configured as requested.]*/ diff --git a/device/core/src/utils.ts b/device/core/src/utils.ts index 485ba5e96..a6c8ce331 100644 --- a/device/core/src/utils.ts +++ b/device/core/src/utils.ts @@ -11,12 +11,39 @@ const packageJson = require('../package.json'); export function getUserAgentString(done: NoErrorCallback): void; export function getUserAgentString(): Promise; -export function getUserAgentString(done?: NoErrorCallback): Promise | void { +export function getUserAgentString(productInfo: string, done: NoErrorCallback): void; +export function getUserAgentString(productInfo: string): Promise; +export function getUserAgentString(productInfoOrDone?: string | NoErrorCallback, doneOrNone?: NoErrorCallback): Promise | void { + let productInfo: string; + let done: NoErrorCallback; + + /*Codes_SRS_NODE_DEVICE_UTILS_41_001: [`getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg.]*/ + if (!productInfoOrDone) { + productInfo = ''; + done = doneOrNone; + } else { + switch (typeof(productInfoOrDone)) { + /*Codes_SRS_NODE_DEVICE_UTILS_41_002: [`getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/()'.]*/ + case 'string': { + productInfo = productInfoOrDone; + done = doneOrNone; + break; + } + case 'function': { + productInfo = ''; + done = > productInfoOrDone; + break; + } + /*Codes_SRS_NODE_DEVICE_UTILS_41_003: [`getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`.]*/ + default: + throw new TypeError('Error: productInfo must be of type \'string\''); + } + } return noErrorCallbackToPromise((_callback) => { /*Codes_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/ getAgentPlatformString((platformString) => { - /*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'.]*/ - _callback(packageJson.name + '/' + packageJson.version + ' (' + platformString + ')'); + /*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'.]*/ + _callback(packageJson.name + '/' + packageJson.version + ' (' + platformString + ')' + productInfo); }); }, done); } diff --git a/device/core/test/_internal_client_test.js b/device/core/test/_internal_client_test.js index 0496c9ede..1eb193d16 100644 --- a/device/core/test/_internal_client_test.js +++ b/device/core/test/_internal_client_test.js @@ -109,7 +109,7 @@ var ModuleClient = require('../lib/module_client').ModuleClient; describe('#setTransportOptions', function () { /*Tests_SRS_NODE_INTERNAL_CLIENT_16_021: [The ‘setTransportOptions’ method shall call the ‘setOptions’ method on the transport object.]*/ - /*Tests_SRS_NODE_INTERNAL_CLIENT_16_022: [The ‘done’ callback shall be invoked with a null error object and a ‘TransportConfigured’ object nce the transport has been configured.]*/ + /*Tests_SRS_NODE_INTERNAL_CLIENT_16_022: [The ‘done’ callback shall be invoked with a null error object and a ‘TransportConfigured’ object once the transport has been configured.]*/ it('calls the setOptions method on the transport object and gives it the options parameter', function (done) { var testOptions = { foo: 42 }; var dummyTransport = new FakeTransport(); @@ -202,6 +202,24 @@ var ModuleClient = require('../lib/module_client').ModuleClient; testCallback(); }); }); + + it('productInfo properly sets string', function (done) { + var testOptions = { productInfo: 'test'}; + var dummyTransport = new FakeTransport(); + sinon.stub(dummyTransport, 'setOptions').callsFake(function (options, callback) { + assert.strictEqual(options.productInfo, testOptions.productInfo); + callback(null, new results.TransportConfigured()); + }); + + var client = new ClientCtor(dummyTransport); + client.setOptions(testOptions, function (err, results) { + if (err) { + done(err); + } else { + done(); + } + }); + }); }); describe('#open', function () { diff --git a/device/core/test/_utils_test.js b/device/core/test/_utils_test.js index 44a89752a..871460da9 100644 --- a/device/core/test/_utils_test.js +++ b/device/core/test/_utils_test.js @@ -11,7 +11,7 @@ var packageJson = require('../package.json'); describe('getUserAgentString', function() { var fakePlatformString = 'fakePlatformString'; - + var fakeProductInfoString = 'fakeProductInfoString'; before(function() { sinon.stub(core, 'getAgentPlatformString').callsArgWith(0, fakePlatformString); }); @@ -20,16 +20,45 @@ describe('getUserAgentString', function() { core.getAgentPlatformString.restore(); }); - /*Codes_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/ - /*Codes_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'.]*/ + /*Tests_SRS_NODE_DEVICE_UTILS_18_001: [`getUserAgentString` shall call `getAgentPlatformString` to get the platform string.]*/ + /*Tests_SRS_NODE_DEVICE_UTILS_18_002: [`getUserAgentString` shall call its `callback` with a string in the form 'azure-iot-device/()'.]*/ it ('returns the right string', function(callback) { - getUserAgentString(function(actual) { - assert.equal(actual, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')'); + getUserAgentString(function(actualAgentString) { + assert.equal(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')'); callback(); }); }); -}); + /*Tests_SRS_NODE_DEVICE_UTILS_41_001: [`getUserAgentString` shall not add any custom product Info if a `falsy` value is passed in as the first arg.]*/ + it('does not populate productInfo for falsy values', function () { + ['', null, undefined].forEach(function (falsyValue) { + getUserAgentString(falsyValue, function(actualAgentString) { + assert.strictEqual(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')'); + }); + }); + }); + + /*Tests_SRS_NODE_DEVICE_UTILS_41_002: [`getUserAgentString` shall accept productInfo as a `string` so that the callback is called with a string in the form 'azure-iot-device/()'.]*/ + it('returns the right string with productInfo', function(callback) { + getUserAgentString(fakeProductInfoString, function(actualAgentString) { + assert.strictEqual(actualAgentString, 'azure-iot-device/' + packageJson.version + ' (' + fakePlatformString + ')' + fakeProductInfoString); + callback(); + }); + }); + + /*Tests_SRS_NODE_DEVICE_UTILS_41_003: [`getUserAgentString` shall throw if the first arg is not `falsy`, or of type `string` or `function`.]*/ + it('throws on wrong type for productInfo', function() { + [41, [5, 1], {test: 'test'}].forEach(function (badValue) { + assert.throws(function() { + getUserAgentString(badValue, function(actualAgentString) { + console.log(actualAgentString); + }); + }); + }); + }); + + +}); diff --git a/device/transport/amqp/devdoc/device_amqp_requirements.md b/device/transport/amqp/devdoc/device_amqp_requirements.md index 44210213e..342b633d9 100644 --- a/device/transport/amqp/devdoc/device_amqp_requirements.md +++ b/device/transport/amqp/devdoc/device_amqp_requirements.md @@ -84,6 +84,10 @@ The `connect` method establishes a connection with the Azure IoT Hub instance. **SRS_NODE_DEVICE_AMQP_99_084: [** The `connect` method shall set the HTTPS agent on the options object when calling the underlying connection object's connect method if it was supplied. **]** +**SRS_NODE_DEVICE_AMQP_41_001: [** The AMQP transport should use the productInfo string in the `options` object if present **]** + +**SRS_NODE_DEVICE_AMQP_41_002: [** The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. **]** + ### disconnect(done) The `disconnect` method terminates the connection with the Azure IoT Hub instance. diff --git a/device/transport/amqp/src/amqp.ts b/device/transport/amqp/src/amqp.ts index 2d1c3db51..3013b7fa1 100644 --- a/device/transport/amqp/src/amqp.ts +++ b/device/transport/amqp/src/amqp.ts @@ -278,13 +278,20 @@ export class Amqp extends EventEmitter implements DeviceTransport { this._d2cEndpoint = endpoint.deviceEventPath(credentials.deviceId); this._messageEventName = 'message'; } - - getUserAgentString((userAgentString) => { + /*Tests_SRS_NODE_DEVICE_AMQP_41_001: [ The AMQP transport should use the productInfo string in the `options` object if present ]*/ + /*Tests_SRS_NODE_DEVICE_AMQP_41_002: [ The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. ]*/ + const customInfo = (this._options && this._options.productInfo) ? this._options.productInfo : ''; + getUserAgentString(customInfo, (userAgentString) => { const config: AmqpBaseTransportConfig = { uri: this._getConnectionUri(credentials), sslOptions: credentials.x509, userAgentString: userAgentString }; + /*Codes_SRS_NODE_DEVICE_AMQP_13_002: [ The connect method shall set the CA cert on the options object when calling the underlying connection object's connect method if it was supplied. ]*/ + // if (this._options && this._options.ca) { + // config.sslOptions = config.sslOptions || {}; + // config.sslOptions.ca = this._options.ca; + // } if (this._options) { config.sslOptions = config.sslOptions || {}; /*Codes_SRS_NODE_DEVICE_AMQP_13_002: [ The connect method shall set the CA cert on the options object when calling the underlying connection object's connect method if it was supplied. ]*/ @@ -688,6 +695,7 @@ export class Amqp extends EventEmitter implements DeviceTransport { } } + /*Codes_SRS_NODE_DEVICE_AMQP_13_001: [ The setOptions method shall save the options passed in. ]*/ this._options = options; if (done) { diff --git a/device/transport/amqp/test/_amqp_test.js b/device/transport/amqp/test/_amqp_test.js index 02ea3b87d..1fa2f7866 100644 --- a/device/transport/amqp/test/_amqp_test.js +++ b/device/transport/amqp/test/_amqp_test.js @@ -15,6 +15,7 @@ var errors = require('azure-iot-common').errors; var results = require('azure-iot-common').results; var AuthenticationType = require('azure-iot-common').AuthenticationType; + describe('Amqp', function () { var transport = null; var receiver = null; @@ -410,6 +411,18 @@ describe('Amqp', function () { testCallback(); }); }); + + /*Tests_SRS_NODE_DEVICE_AMQP_41_002: [ The connect method shall set the productInfo on the options object when calling the underlying connection object's connect method if it was supplied. ]*/ + it('sets productInfo if provided', function (testCallback) { + var options = { productInfo: 'test: THIS IS A TEST'}; + transport.setOptions(options); + transport.connect(function (err) { + assert.isNotOk(err); + assert(fakeBaseClient.connect.called); + assert(fakeBaseClient.connect.firstCall.args[0].userAgentString.includes(options.productInfo)); + testCallback(); + }); + }); it('sets gateway host name if provided', function (testCallback) { fakeTokenAuthenticationProvider.getDeviceCredentials = sinon.stub().callsArgWith(0, null, configWithGatewayHostName); @@ -881,10 +894,16 @@ describe('Amqp', function () { }); /*Tests_SRS_NODE_DEVICE_AMQP_13_001: [ The setOptions method shall save the options passed in. ]*/ - it('saves options', function () { + it('saves CA options', function () { transport.setOptions({ ca: 'ca cert' }); assert.strictEqual(transport._options.ca, 'ca cert'); }); + + /*Tests_SRS_NODE_DEVICE_AMQP_41_001: [The AMQP transport should use the productInfo string in the `options` object if present]*/ + it('saves productInfo options', function () { + transport.setOptions({ productInfo: 'customer user agent information' }); + assert.strictEqual(transport._options.productInfo, 'customer user agent information'); + }); }); describe('on(\'disconnect\')', function () { diff --git a/device/transport/http/devdoc/http_requirements.md b/device/transport/http/devdoc/http_requirements.md index 9c0260aab..cced4db89 100644 --- a/device/transport/http/devdoc/http_requirements.md +++ b/device/transport/http/devdoc/http_requirements.md @@ -158,11 +158,17 @@ Host: **SRS_NODE_DEVICE_HTTP_RECEIVER_16_017: [**If `opts.drain` is true all messages in the queue should be pulled at once.**]** -**SRS_NODE_DEVICE_HTTP_RECEIVER_16_018: [**If `opts.drain` is false, only one message shall be received at a time**]** +**SRS_NODE_DEVICE_HTTP_RECEIVER_16_018: [**If `opts.drain` is false, only one message shall be received at a time **]** -**SRS_NODE_DEVICE_HTTP_RECEIVER_16_019: [**If the receiver is already running with a previous configuration, the existing receiver should be restarted with the new configuration**]** +**SRS_NODE_DEVICE_HTTP_RECEIVER_16_019: [**If the receiver is already running with a previous configuration, the existing receiver should be restarted with the new configuration **]** -**SRS_NODE_DEVICE_HTTP_RECEIVER_16_023: [**If `opts.manualPolling` is true, messages shall be received only when receive() is called**]** +**SRS_NODE_DEVICE_HTTP_RECEIVER_16_023: [**If `opts.manualPolling` is true, messages shall be received only when receive() is called **]** + +**SRS_NODE_DEVICE_HTTP_41_001: [** The HTTP transport should use the productInfo string in the `options` object if present **]** + +**SRS_NODE_DEVICE_HTTP_41_002: [** `productInfo` should be set in the HTTP User-Agent Header if set using `setOptions` **]** + +**SRS_NODE_DEVICE_HTTP_41_003: [** `productInfo` must be set before `http._ensureAgentString` is invoked for the first time **]** ### abandon(message, done) diff --git a/device/transport/http/src/http.ts b/device/transport/http/src/http.ts index 0914a8d5d..edef0095a 100644 --- a/device/transport/http/src/http.ts +++ b/device/transport/http/src/http.ts @@ -72,6 +72,7 @@ export class Http extends EventEmitter implements DeviceTransport { private _timeoutObj: number; private _receiverStarted: boolean; private _userAgentString: string; + private _productInfo: string; /** * @private @@ -159,6 +160,7 @@ export class Http extends EventEmitter implements DeviceTransport { done(err); } else { const path = endpoint.deviceEventPath(encodeUriComponentStrict(config.deviceId)); + /*Codes_SRS_NODE_DEVICE_HTTP_41_002: [ `productInfo` should be set in the HTTP User-Agent Header if set using `setOptions` ]*/ let httpHeaders = { 'iothub-to': path, 'User-Agent': this._userAgentString @@ -325,13 +327,22 @@ export class Http extends EventEmitter implements DeviceTransport { }); } + /*Codes_SRS_NODE_DEVICE_HTTP_41_001: [ The HTTP transport should use the productInfo string in the `options` object if present ]*/ + if (options.productInfo) { + // To enforce proper use of the productInfo option, if the setOption is called after HTTP calls have already been made (therefore _userAgentString already set) an error is thrown. + if (this._userAgentString) { + /*Codes_SRS_NODE_DEVICE_HTTP_41_003: [ `productInfo` must be set before `http._ensureAgentString` is invoked for the first time ]*/ + throw Error('Ensure you call setOption for productInfo before initiating any connection to IoT Hub'); + } else { + this._productInfo = options.productInfo; + } + } + /*Codes_SRS_NODE_DEVICE_HTTP_16_010: [`setOptions` should not throw if `done` has not been specified.]*/ /*Codes_SRS_NODE_DEVICE_HTTP_16_005: [If `done` has been specified the `setOptions` method shall call the `done` callback with no arguments when successful.]*/ /*Codes_SRS_NODE_DEVICE_HTTP_16_009: [If `done` has been specified the `setOptions` method shall call the `done` callback with a standard javascript `Error` object when unsuccessful.]*/ this._http.setOptions(options); - this._http.setOptions(options); - // setOptions used to exist both on Http and HttpReceiver with different options class. In order not to break backward compatibility we have // to check what properties this options object has to figure out what to do with it. if (options.hasOwnProperty('http') && options.http.hasOwnProperty('receivePolicy')) { @@ -786,10 +797,11 @@ export class Http extends EventEmitter implements DeviceTransport { if (this._userAgentString) { done(); } else { - getUserAgentString((agent) => { + getUserAgentString(this._productInfo, (agent) => { this._userAgentString = agent; done(); }); } } } + diff --git a/device/transport/http/test/_http_test.js b/device/transport/http/test/_http_test.js index 4c479fb77..77cc8c3df 100644 --- a/device/transport/http/test/_http_test.js +++ b/device/transport/http/test/_http_test.js @@ -17,6 +17,8 @@ var FakeHttp = function () { }; FakeHttp.prototype.buildRequest = function (method, path, httpHeaders, host, sslOptions, done) { return { + write: function () { + }, end: function () { if (this.messageCount > 0) { this.messageCount--; @@ -35,6 +37,7 @@ FakeHttp.prototype.setMessageCount = function (messageCount) { }; FakeHttp.prototype.setOptions = function(options, callback) { + if (callback) callback(); }; @@ -335,6 +338,7 @@ describe('Http', function () { receivePolicy: {interval: 1} } }; + var fakeProductInfoString = 'fakeProductInfoString'; /*Tests_SRS_NODE_DEVICE_HTTP_16_005: [If `done` has been specified the `setOptions` method shall call the `done` callback with no arguments when successful.]*/ it('calls the done callback with no arguments if successful', function(done) { @@ -349,11 +353,57 @@ describe('Http', function () { transport.setOptions({}); }); }); + + /*Tests_SRS_NODE_DEVICE_HTTP_41_001: [ The HTTP transport should use the productInfo string in the `options` object if present ]*/ + /*Tests_SRS_NODE_DEVICE_HTTP_41_002: [ `productInfo` should be set in the HTTP User-Agent Header if set using `setOptions` ]*/ + it('productInfo is included in the \'User-Agent\' header during the HTTP buildRequest', function() { + var MockHttp = { + setOptions: function () {}, + buildRequest: function() {} + }; + var spy = sinon.stub(MockHttp, 'buildRequest').returns({ + write: function() {}, + end: function() {} + }); + + var http = new Http(fakeAuthenticationProvider, MockHttp); + http.setOptions({ productInfo: fakeProductInfoString }); + assert.exists(http._productInfo); + + var msg = new Message('fakeBody'); + + http.sendEvent(msg, () => {}); + + var actualUserAgent = http._http.buildRequest.args[0][2]['User-Agent']; + assert(actualUserAgent.includes(fakeProductInfoString)); + }); + + /*Tests_SRS_NODE_DEVICE_HTTP_41_003: [`productInfo` must be set before `http._ensureAgentString` is invoked for the first time]*/ + it('throws if productInfo is set after HTTP has established a connection', function() { + var MockHttp = { + setOptions: function () {}, + buildRequest: function() {} + }; + var spy = sinon.stub(MockHttp, 'buildRequest').returns({ + write: function() {}, + end: function() {} + }); + + var http = new Http(fakeAuthenticationProvider, MockHttp); + var msg = new Message('fakeBody'); + + http.sendEvent(msg, () => { + assert.throws(() => {http.setOptions({ productInfo: fakeProductInfoString }); + }); + }); + }); + + }); describe('#updateSharedAccessSignature', function() { - /*Codes_SRS_NODE_DEVICE_HTTP_16_006: [The updateSharedAccessSignature method shall save the new shared access signature given as a parameter to its configuration.] */ - /*Codes_SRS_NODE_DEVICE_HTTP_16_007: [The updateSharedAccessSignature method shall call the `done` callback with a null error object and a SharedAccessSignatureUpdated object as a result, indicating that the client does not need to reestablish the transport connection.] */ + /*Tests_SRS_NODE_DEVICE_HTTP_16_006: [The updateSharedAccessSignature method shall save the new shared access signature given as a parameter to its configuration.] */ + /*Tests_SRS_NODE_DEVICE_HTTP_16_007: [The updateSharedAccessSignature method shall call the `done` callback with a null error object and a SharedAccessSignatureUpdated object as a result, indicating that the client does not need to reestablish the transport connection.] */ it('updates its configuration object with the new shared access signature', function(done) { var transportWithoutReceiver = new Http(fakeAuthenticationProvider); var newSas = 'newsas'; diff --git a/device/transport/mqtt/devdoc/mqtt_requirements.md b/device/transport/mqtt/devdoc/mqtt_requirements.md index a53368067..9d1379f6d 100644 --- a/device/transport/mqtt/devdoc/mqtt_requirements.md +++ b/device/transport/mqtt/devdoc/mqtt_requirements.md @@ -205,6 +205,13 @@ The `reject` method is there for compatibility purposes with other transports bu **SRS_NODE_DEVICE_MQTT_16_070: [** The `setOptions` method shall call its callback with the error returned by `getDeviceCredentials` if it fails to return the credentials. **]** +**_SRS_NODE_DEVICE_MQTT_41_001: [** The MQTT transport should use the productInfo string in the `options` object if present **]** + +**_SRS_NODE_DEVICE_MQTT_41_002: [** The MQTT constructor shall append the productInfo to the `username` property of the `config` object. **]** + +**_SRS_NODE_DEVICE_MQTT_41_003: [** `productInfo` must be set before `mqtt._ensureAgentString` is invoked for the first time **]** + + ### onDeviceMethod(methodName, methodCallback) **SRS_NODE_DEVICE_MQTT_16_066: [** The `methodCallback` parameter shall be called whenever a `method_` is emitted and device methods have been enabled. **]** diff --git a/device/transport/mqtt/src/mqtt.ts b/device/transport/mqtt/src/mqtt.ts index 553f8fabe..24c3cc379 100644 --- a/device/transport/mqtt/src/mqtt.ts +++ b/device/transport/mqtt/src/mqtt.ts @@ -43,6 +43,7 @@ export class Mqtt extends EventEmitter implements DeviceTransport { private _fsm: machina.Fsm; private _topics: { [key: string]: TopicDescription }; private _userAgentString: string; + private _productInfo: string; /** * @private @@ -485,6 +486,17 @@ export class Mqtt extends EventEmitter implements DeviceTransport { /*Codes_SRS_NODE_DEVICE_MQTT_16_015: [The `setOptions` method shall throw an `ArgumentError` if the `cert` property is populated but the device uses symmetric key authentication.]*/ if (this._authenticationProvider.type === AuthenticationType.Token && options.cert) throw new errors.ArgumentError('Cannot set x509 options on a device that uses token authentication.'); + /*Codes_SRS_NODE_DEVICE_MQTT_41_001: [The MQTT transport should use the productInfo string in the `options` object if present]*/ + if (options.productInfo) { + // To enforce proper use of the productInfo option, if the setOption is called after HTTP calls have already been made (therefore _userAgentString already set) an error is thrown. + if (this._userAgentString) { + /*Codes_SRS_NODE_DEVICE_MQTT_41_003: [`productInfo` must be set before `mqtt._ensureAgentString` is invoked for the first time]*/ + throw Error('Ensure you call setOption for productInfo before initiating any connection to IoT Hub'); + } else { + this._productInfo = options.productInfo; + } + } + this._mqtt.setOptions(options); if (!options.cert) { @@ -660,6 +672,7 @@ export class Mqtt extends EventEmitter implements DeviceTransport { /*Codes_SRS_NODE_DEVICE_MQTT_16_016: [If the connection string does not specify a `gatewayHostName` value, the Mqtt constructor shall initialize the `uri` property of the `config` object to `mqtts://`.]*/ /*Codes_SRS_NODE_DEVICE_MQTT_18_054: [If a `gatewayHostName` is specified in the connection string, the Mqtt constructor shall initialize the `uri` property of the `config` object to `mqtts://`. ]*/ /*Codes_SRS_NODE_DEVICE_MQTT_18_055: [The Mqtt constructor shall initialize the `username` property of the `config` object to '//api-version=&DeviceClientType='. ]*/ + /*Tests_SRS_NODE_DEVICE_MQTT_41_002: [The MQTT constructor shall append the productInfo to the `username` property of the `config` object.]*/ let baseConfig: MqttBaseTransportConfig = { uri: 'mqtts://' + (credentials.gatewayHostName || credentials.host), username: credentials.host + '/' + clientId + @@ -741,8 +754,8 @@ export class Mqtt extends EventEmitter implements DeviceTransport { /*Codes_SRS_NODE_DEVICE_MQTT_18_065: [`disableInputMessages` shall unsubscribe from the topic for inputMessages. ]*/ this._mqtt.unsubscribe(topic.name, (err) => { topic.subscribed = !err; - /*Tests_SRS_NODE_DEVICE_MQTT_16_054: [`disableC2D` shall call its callback with no arguments when the `UNSUBACK` packet is received.]*/ - /*Tests_SRS_NODE_DEVICE_MQTT_16_055: [`disableMethods` shall call its callback with no arguments when the `UNSUBACK` packet is received.]*/ + /*Codes_SRS_NODE_DEVICE_MQTT_16_054: [`disableC2D` shall call its callback with no arguments when the `UNSUBACK` packet is received.]*/ + /*Codes_SRS_NODE_DEVICE_MQTT_16_055: [`disableMethods` shall call its callback with no arguments when the `UNSUBACK` packet is received.]*/ /*Codes_SRS_NODE_DEVICE_MQTT_18_066: [`disableInputMessages` shall call its callback with no arguments when the `UNSUBACK` packet is received. ]*/ /*Codes_SRS_NODE_DEVICE_MQTT_16_043: [`disableC2D` shall call its callback with an `Error` if an error is received while unsubscribing.]*/ /*Codes_SRS_NODE_DEVICE_MQTT_16_046: [`disableMethods` shall call its callback with an `Error` if an error is received while unsubscribing.]*/ @@ -925,11 +938,12 @@ export class Mqtt extends EventEmitter implements DeviceTransport { return topic; } - private _ensureAgentString(done: () => void): void { + private _ensureAgentString(done: () => void): void { if (this._userAgentString) { done(); } else { - getUserAgentString((agent) => { + const customInfo = (this._productInfo) ? this._productInfo : ''; + getUserAgentString(customInfo, (agent) => { this._userAgentString = agent; done(); }); diff --git a/device/transport/mqtt/test/_mqtt_test.js b/device/transport/mqtt/test/_mqtt_test.js index cece2bd2e..8d101ffb5 100644 --- a/device/transport/mqtt/test/_mqtt_test.js +++ b/device/transport/mqtt/test/_mqtt_test.js @@ -39,6 +39,7 @@ describe('Mqtt', function () { fakeMqttBase.subscribe = sinon.stub().callsArg(2); fakeMqttBase.unsubscribe = sinon.stub().callsArg(1); fakeMqttBase.updateSharedAccessSignature = sinon.stub().callsArg(1); + fakeMqttBase.setOptions = sinon.stub().callsFake(() => {}); }); afterEach(function () { @@ -488,6 +489,7 @@ describe('Mqtt', function () { x509: fakeX509Options }; + var fakeX509AuthenticationProvider; beforeEach(function () { @@ -555,8 +557,53 @@ describe('Mqtt', function () { testCallback(); }); }); + + /*Tests_SRS_NODE_DEVICE_MQTT_41_001: [The MQTT transport should use the productInfo string in the `options` object if present]*/ + /*Tests_SRS_NODE_DEVICE_MQTT_41_002: [The MQTT constructor shall append the productInfo to the `username` property of the `config` object.]*/ + it('sets options for productInfo', function(testCallback) { + var fakeProductInfoString = 'fakeProductInfoString'; + var fakeProductInfoOptions = {productInfo: fakeProductInfoString} + var connectCallback; + fakeMqttBase.connect = sinon.stub().callsFake(function (config, callback) { + connectCallback = callback; + }); + var mqtt = new Mqtt(fakeAuthenticationProvider, fakeMqttBase); + mqtt.setOptions(fakeProductInfoOptions, function (err) { + assert.strictEqual(mqtt._productInfo, fakeProductInfoOptions.productInfo); + getUserAgentString(fakeProductInfoString, function(userAgentString) { + var expectedUsername = 'host.name/deviceId/' + endpoint.versionQueryString() + '&DeviceClientType=' + encodeURIComponent(userAgentString); + mqtt.connect(function () { + assert.strictEqual(fakeMqttBase.connect.firstCall.args[0]['username'], expectedUsername); + testCallback(); + }); + connectCallback(); + }); + }); + }); + + /*Tests_SRS_NODE_DEVICE_MQTT_41_003: [`productInfo` must be set before `mqtt._ensureAgentString` is invoked for the first time]*/ + it('throws if productInfo is set after mqtt connects', function(testCallback) { + var fakeProductInfoString = 'fakeProductInfoString'; + var fakeProductInfoOptions = {productInfo: fakeProductInfoString} + var connectCallback; + fakeMqttBase.connect = sinon.stub().callsFake(function (config, callback) { + connectCallback = callback; + }); + var mqtt = new Mqtt(fakeAuthenticationProvider, fakeMqttBase); + getUserAgentString(fakeProductInfoString, function(userAgentString) { + var expectedUsername = 'host.name/deviceId/' + endpoint.versionQueryString() + '&DeviceClientType=' + encodeURIComponent(userAgentString); + mqtt.connect(function () { + assert.throw(function () { + mqtt.setOptions(fakeProductInfoOptions, function (err) {}); + }); + testCallback(); + }); + connectCallback(); + }); + }); }); + describe('#connect', function() { /* Tests_SRS_NODE_DEVICE_MQTT_12_004: [The connect method shall call the connect method on MqttBase */ it ('calls connect on the transport', function(testCallback) { @@ -608,7 +655,7 @@ describe('Mqtt', function () { fieldValueToCheck: 'host.name/deviceId/' + endpoint.versionQueryString() + '&DeviceClientType=' + encodeURIComponent(userAgentString) } ].forEach(function (testConfig) { - it('sets the ' + testConfig.fieldNameToCheck + ' to \'' + testConfig.fieldValueToCheck + '\'', function (testCallback) { + it ('sets the ' + testConfig.fieldNameToCheck + ' to \'' + testConfig.fieldValueToCheck + '\'', function (testCallback) { if (testConfig.fieldNameToSet) { fakeConfig[testConfig.fieldNameToSet] = testConfig.fieldValueToSet; }