From cb3c2f27beccf1f6b99c2151c716a6d700bbd142 Mon Sep 17 00:00:00 2001 From: Nathan Bryant <60950167+nbry@users.noreply.github.com> Date: Thu, 17 Aug 2023 09:12:21 -0700 Subject: [PATCH] feat: vehicle management functions (#162) --- doc/readme.md | 54 +++++++++++ index.js | 149 +++++++++++++++++++++++++------ lib/config.json | 1 + lib/util.js | 14 +++ package.json | 2 +- test/end-to-end/helpers/index.js | 10 +++ test/end-to-end/index.js | 58 +++++++++++- test/end-to-end/vehicle.js | 2 +- test/unit/index.js | 13 +++ test/unit/lib/util.js | 7 ++ 10 files changed, 282 insertions(+), 28 deletions(-) diff --git a/doc/readme.md b/doc/readme.md index 722a8fc..0c988bc 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -102,6 +102,8 @@ the following fields :

* [~User](#module_smartcar..User) : Object * [~VehicleIds](#module_smartcar..VehicleIds) : Object * [~Compatibility](#module_smartcar..Compatibility) : Object + * [~GetConnections](#module_smartcar..GetConnections) ⇒ GetConnections + * [~DeleteConnections](#module_smartcar..DeleteConnections) ⇒ DeleteConnections @@ -321,6 +323,58 @@ Verify webhook payload with AMT and signature. } } ``` + + +### smartcar~GetConnections ⇒ GetConnections +Returns a paged list of all the vehicles that are connected to the application associated +with the management API token used sorted in descending order by connection date. + +**Kind**: inner typedef of [smartcar](#module_smartcar) + +| Param | Type | Description | +| --- | --- | --- | +| amt | String | Application Management Token | +| filter | object | | +| filter.userId | String | | +| filter.vehicleId | String | | +| paging | object | | +| paging.limit | number | | +| paging.cursor | String | | + +**Properties** + +| Name | Type | +| --- | --- | +| vehicleId | String | +| userId | String | +| connectedAt | String | +| connections | Array.<Connection> | +| [paging] | Object | +| [paging.cursor] | string | + + + +### smartcar~DeleteConnections ⇒ DeleteConnections +Deletes all the connections by vehicle or user ID and returns a +list of all connections that were deleted. + +**Kind**: inner typedef of [smartcar](#module_smartcar) + +| Param | Type | Description | +| --- | --- | --- | +| amt | String | Application Management Token | +| filter | object | | +| filter.userId | String | | +| filter.vehicleId | String | | + +**Properties** + +| Name | Type | +| --- | --- | +| vehicleId | String | +| userId | String | +| connections | Array.<Connection> | + ## AuthClient diff --git a/index.js b/index.js index eecb0d9..f9e9a07 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,8 @@ +/* eslint-disable max-len */ +/* eslint-disable camelcase */ 'use strict'; +const _ = require('lodash'); const crypto = require('crypto'); const {emitWarning} = require('process'); @@ -29,15 +32,16 @@ const buildQueryParams = function(vin, scope, country, options) { parameters.flags = util.getFlagsString(options.flags); } if (options.hasOwnProperty('testMode')) { - emitWarning(// eslint-disable-next-line max-len + emitWarning( + // eslint-disable-next-line max-len 'The "testMode" parameter is deprecated, please use the "mode" parameter instead.', ); parameters.mode = options.testMode === true ? 'test' : 'live'; } else if (options.hasOwnProperty('mode')) { parameters.mode = options.mode; if (!['test', 'live', 'simulated'].includes(parameters.mode)) { - throw new Error(// eslint-disable-next-line max-len - 'The "mode" parameter MUST be one of the following: \'test\', \'live\', \'simulated\'', + throw new Error( // eslint-disable-next-line max-len + "The \"mode\" parameter MUST be one of the following: 'test', 'live', 'simulated'", ); } } @@ -65,7 +69,7 @@ smartcar.setApiVersion = function(version) { * @method * @return {String} version */ -smartcar.getApiVersion = () => (config.version); +smartcar.getApiVersion = () => config.version; /** * @type {Object} @@ -96,10 +100,7 @@ smartcar.getUser = async function(accessToken) { const response = await new SmartcarService({ baseUrl: util.getConfig('SMARTCAR_API_ORIGIN') || config.api, auth: {bearer: accessToken}, - }).request( - 'get', - `/v${config.version}/user`, - ); + }).request('get', `/v${config.version}/user`); return response; }; @@ -213,17 +214,12 @@ smartcar.getVehicles = async function(accessToken, paging = {}) { * See the [errors section](https://github.com/smartcar/node-sdk/tree/master/doc#errors) * for all possible errors. */ -smartcar.getCompatibility = async function( - vin, - scope, - country, - options = {}, -) { +smartcar.getCompatibility = async function(vin, scope, country, options = {}) { country = country || 'US'; - const clientId = options.clientId - || util.getOrThrowConfig('SMARTCAR_CLIENT_ID'); - const clientSecret = options.clientSecret - || util.getOrThrowConfig('SMARTCAR_CLIENT_SECRET'); + const clientId = + options.clientId || util.getOrThrowConfig('SMARTCAR_CLIENT_ID'); + const clientSecret = + options.clientSecret || util.getOrThrowConfig('SMARTCAR_CLIENT_SECRET'); const response = await new SmartcarService({ baseUrl: util.getConfig('SMARTCAR_API_ORIGIN') || config.api, @@ -232,10 +228,7 @@ smartcar.getCompatibility = async function( pass: clientSecret, }, qs: buildQueryParams(vin, scope, country, options), - }).request( - 'get', - `v${options.version || config.version}/compatibility`, - ); + }).request('get', `v${options.version || config.version}/compatibility`); return response; }; @@ -261,8 +254,114 @@ smartcar.hashChallenge = function(amt, challenge) { * @param {object} body - webhook response body * @return {Boolean} true if signature matches the hex digest of amt and body */ -smartcar.verifyPayload = (amt, signature, body) => ( - smartcar.hashChallenge(amt, JSON.stringify(body)) === signature -); +smartcar.verifyPayload = (amt, signature, body) => + smartcar.hashChallenge(amt, JSON.stringify(body)) === signature; + +/** + * Returns a paged list of all the vehicles that are connected to the application associated + * with the management API token used sorted in descending order by connection date. + * + * @type {Object} + * @typedef Connection + * @property {String} vehicleId + * @property {String} userId + * @property {String} connectedAt + * + * @type {Object} + * @typedef GetConnections + * @property {Connection[]} connections + * @property {Object} [paging] + * @property {string} [paging.cursor] + * + * @param {String} amt - Application Management Token + * @param {object} filter + * @param {String} filter.userId + * @param {String} filter.vehicleId + * @param {object} paging + * @param {number} paging.limit + * @param {String} paging.cursor + * @returns {GetConnections} + */ +smartcar.getConnections = async function(amt, filter = {}, paging = {}) { + const {userId, vehicleId} = _.pick(filter, ['userId', 'vehicleId']); + const {limit, cursor} = _.pick(paging, ['limit', 'cursor']); + + const qs = {}; + if (userId) { + qs.user_id = userId; + } + if (vehicleId) { + qs.vehicle_id = vehicleId; + } + if (limit) { + qs.limit = limit; + } + // istanbul ignore next + if (cursor) { + qs.cursor = cursor; + } + + const response = await new SmartcarService({ + // eslint-disable-next-line max-len + baseUrl: + `${util.getConfig('SMARTCAR_MANAGEMENT_API_ORIGIN') || config.management}/v${config.version}`, + auth: { + user: 'default', + pass: amt, + }, + qs, + }).request('get', '/management/connections'); + + return response; +}; + +/** + * Deletes all the connections by vehicle or user ID and returns a + * list of all connections that were deleted. + * + * @type {Object} + * @typedef Connection + * @property {String} vehicleId + * @property {String} userId + * + * @type {Object} + * @typedef DeleteConnections + * @property {Connection[]} connections + * + * @param {String} amt - Application Management Token + * @param {object} filter + * @param {String} filter.userId + * @param {String} filter.vehicleId + * @returns {DeleteConnections} + */ +smartcar.deleteConnections = async function(amt, filter) { + const {userId, vehicleId} = _.pick(filter, ['userId', 'vehicleId']); + if (userId && vehicleId) { + // eslint-disable-next-line max-len + throw new Error( + 'Filter can contain EITHER user_id OR vehicle_id, not both', + ); + } + + const qs = {}; + if (userId) { + qs.user_id = userId; + } + if (vehicleId) { + qs.vehicle_id = vehicleId; + } + + const response = await new SmartcarService({ + baseUrl: + `${util.getConfig('SMARTCAR_MANAGEMENT_API_ORIGIN') || config.management}/v${config.version}`, + auth: { + user: 'default', + pass: amt, + }, + qs, + }).request('delete', '/management/connections'); + + return response; +}; module.exports = smartcar; diff --git a/lib/config.json b/lib/config.json index d21d4b3..1deb2b7 100644 --- a/lib/config.json +++ b/lib/config.json @@ -2,6 +2,7 @@ "auth": "https://auth.smartcar.com", "api": "https://api.smartcar.com", "connect": "https://connect.smartcar.com", + "management": "https://management.smartcar.com", "timeout": 310000, "version": "2.0" } diff --git a/lib/util.js b/lib/util.js index e9accb3..4afa532 100644 --- a/lib/util.js +++ b/lib/util.js @@ -112,4 +112,18 @@ util.handleError = function(caught) { } }; +/** + * + * Generate the token for vehicle management APIs using the amt. + * + * @method + * @param {String} amt - Application Management Token + * @param {String} username + * @return {String} managementToken + */ +util.getManagementToken = function(amt, username = 'default') { + const credentials = `${username}:${amt}`; + return Buffer.from(credentials).toString('base64'); +}; + module.exports = util; diff --git a/package.json b/package.json index d615114..2b71c7f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "scripts": { "test": "npm run test:unit && npm run test:e2e", "test:unit": "ava test/unit", - "test:e2e": "ava --timeout=60s test/end-to-end", + "test:e2e": "ava --timeout=120s test/end-to-end", "test:integration": "cross-env NOCK_OFF=true npm test", "posttest": "npm run lint -s", "lint": "eslint . --cache", diff --git a/test/end-to-end/helpers/index.js b/test/end-to-end/helpers/index.js index ca8e9a8..e55fd07 100644 --- a/test/end-to-end/helpers/index.js +++ b/test/end-to-end/helpers/index.js @@ -87,6 +87,16 @@ helpers.runAuthFlow = async function( ); await continueButton.click(); + // Filter + if (brand === 'KIA') { + const brandInput = await driver.wait( + until.elementLocated( + By.css('input[id=brand-search]'), + ), + ); + await brandInput.sendKeys(brand); + } + // Brand Selector const brandButton = await driver.wait( until.elementLocated( diff --git a/test/end-to-end/index.js b/test/end-to-end/index.js index 2b1f50a..5480b82 100644 --- a/test/end-to-end/index.js +++ b/test/end-to-end/index.js @@ -4,14 +4,18 @@ const _ = require('lodash'); const test = require('ava'); const smartcar = require('../../'); +const util = require('../../lib/util'); const {getAuthClientParams, runAuthFlow, DEFAULT_SCOPES} = require('./helpers'); test.before(async(t) => { const client = new smartcar.AuthClient(getAuthClientParams()); const code = await runAuthFlow(client.getAuthUrl(DEFAULT_SCOPES)); const {accessToken} = await client.exchangeCode(code); + const {vehicles} = await smartcar.getVehicles(accessToken); + const {id: userId} = await smartcar.getUser(accessToken); + t.context.userId = userId; t.context.accessToken = accessToken; - // t.context.accessToken = 'f14a1599-b5d9-4fe7-bff0-c890f837b7b4'; + t.context.connectedVehicles = vehicles; }); test('getVehicles', async(t) => { @@ -81,3 +85,55 @@ test('getCompatibility', async(t) => { t.truthy(_.every(audiComp.capabilities, ['capable', false])); t.truthy(_.every(teslaComp.capabilities, ['capable', true])); }); + +test.serial('getConnections', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const res = await smartcar.getConnections(amt); + t.truthy(res.connections[0].userId); + t.truthy(res.connections[0].vehicleId); + t.truthy(res.connections[0].connectedAt); +}); + +test.serial('getConnections - by vehicleId', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const testVehicleId = t.context.connectedVehicles[0]; + const res = await smartcar.getConnections(amt, {vehicleId: testVehicleId}); + t.is(res.connections.length, 1); + t.is(res.connections[0].vehicleId, testVehicleId); +}); + +test.serial('getConnections - by userId', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const res = await smartcar.getConnections(amt, {userId: t.context.userId}); + t.is(res.connections.length, t.context.connectedVehicles.length); + for (const connection of res.connections) { + t.is(connection.userId, t.context.userId); + } +}); + +test.serial('getConnections - by userId - limit 1', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const res = await smartcar.getConnections(amt, + {userId: t.context.userId}, + {limit: 1}, + ); + t.is(res.connections.length, t.context.connectedVehicles.length); +}); + +test.serial('deleteConnections - by vehicleId', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const testVehicleId = t.context.connectedVehicles[0]; + const res = await smartcar.deleteConnections(amt, {vehicleId: testVehicleId}); + t.is(res.connections.length, 1); + t.is(res.connections[0].vehicleId, testVehicleId); +}); + +test.serial('deleteConnections - by userId', async(t) => { + const amt = util.getOrThrowConfig('E2E_SMARTCAR_AMT'); + const res = await smartcar.deleteConnections(amt, {userId: t.context.userId}); + // to account for serial test above + t.is(res.connections.length, t.context.connectedVehicles.length - 1); + for (const connection of res.connections) { + t.is(connection.userId, t.context.userId); + } +}); diff --git a/test/end-to-end/vehicle.js b/test/end-to-end/vehicle.js index 523f18b..925cc2f 100644 --- a/test/end-to-end/vehicle.js +++ b/test/end-to-end/vehicle.js @@ -474,7 +474,7 @@ test('vehicle request - set charge limit', async(t) => { }); test.after.always('vehicle disconnect', async(t) => { - const response = await t.context.volt.disconnect(); + const response = await t.context.kia.disconnect(); t.deepEqual( _.xor(_.keys(response), [ 'status', diff --git a/test/unit/index.js b/test/unit/index.js index 84307b4..db0ad19 100644 --- a/test/unit/index.js +++ b/test/unit/index.js @@ -214,3 +214,16 @@ test('getCompatibility - with test_mode false [deprecated]', async function(t) { t.is(response.pizza, 'pasta'); t.true(n.isDone()); }); + +test('deleteConnections - both vehicleId and userId passed', async function(t) { + const error = await t.throwsAsync( + smartcar.deleteConnections('fake-amt', { + vehicleId: 'vehicle id', + userId: 'user id', + }), + ); + t.is( + error.message, + 'Filter can contain EITHER user_id OR vehicle_id, not both', + ); +}); diff --git a/test/unit/lib/util.js b/test/unit/lib/util.js index 2678f1c..d83b8fc 100644 --- a/test/unit/lib/util.js +++ b/test/unit/lib/util.js @@ -268,3 +268,10 @@ test('handleError - SmartcarError V2 with all attrbutes', function(t) { t.is(boxed.message, 'type:code - description'); t.is(boxed.detail[0], 'pizza'); }); + +test('getManagementToken', function(t) { + const res = util.getManagementToken('amt'); + t.is(res, 'ZGVmYXVsdDphbXQ='); + const res2 = util.getManagementToken('amt', 'default'); + t.is(res2, 'ZGVmYXVsdDphbXQ='); +});