From 8a8aec349638fa6a145290a6cac02bee877aef3e Mon Sep 17 00:00:00 2001 From: Rafal Jonca Date: Mon, 20 Jan 2014 15:24:53 +0100 Subject: [PATCH] Added clients creation logic with elastic settings (+ tests). Added tables listing, creation and removal (automated tests will be in done next). --- .gitignore | 3 +- README.md | 119 +++++++++++++++++- index.js | 68 +++++++++++ lib/client.js | 206 ++++++++++++++++++++++++++++++++ lib/utils.js | 59 +++++++++ package.json | 15 ++- test/all.js | 32 +++++ test/test-createClient.js | 70 +++++++++++ test/test-defaultClient.js | 64 ++++++++++ test/test-parseAccountString.js | 36 ++++++ 10 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 lib/client.js create mode 100644 lib/utils.js create mode 100644 test/test-createClient.js create mode 100644 test/test-defaultClient.js create mode 100644 test/test-parseAccountString.js diff --git a/.gitignore b/.gitignore index 8453f8b..d250ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.pid npm-debug.log node_modules -.idea \ No newline at end of file +.idea +hydepark \ No newline at end of file diff --git a/README.md b/README.md index dd58cb3..7dfd599 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,122 @@ azure-table-node Simplified Azure Table Storage client library for NodeJS. +*This is a work in progress. Not usable yet!* + Usage -===== +============== + +## Setting the default client connection info and other settings + +Default client uses environment variable to set up the access key and storage URL if possible. It looks for the `CLOUD_STORAGE_ACCOUNT` setting with three elements (it is the default format used by Azure Storage): + +``` +TableEndpoint=http://accountName.table.core.windows.net/;AccountName=accountName;AccountKey=theKey +``` + +No error is returned if this doesn't exists, is incomplete or malformed. + +*Current version does not support quotes and AccountKey must be the last one to be parsed correctly. This will be fixed in the future.* + +If the environment variable is not set, the default connection info have to be set using below command to be usable: + +```javascript +var azureTable = require('azure-table-node'); +azureTable.setDefaultClient({ + accountUrl: 'http://accountName.table.core.windows.net/', + accountName: 'accountName', + accountKey: 'theKey' +}); +``` + +The same method allows to set other default client settings (see *Client settings*). + +## Using default client + +```javascript +var azureTable = require('azure-table-node'); +var defaultClient = azureTable.getDefaultClient(); + +// use the client to create the table +defaultClient.createTable('tableName', true, cb); + +defaultClient.insert('table', entity, options, cb); +``` + +## Creating customized client + +It is possible to create additional clients that are based on other client (or on default settings), but customized and independent. This allows to for example use several table storage accounts but still have one default for convenience. + +```javascript +var azureTable = require('azure-table-node'); +var tableClient = azureTable.createClient({ + // predefined settings +}, [baseClient]); + +``` + +Base client is the client on which the new one will be based. If not provided, it is based on the default one. + +Client settings +=============== + +Account related: + +* `accountUrl` (string) - URL of the service's endpoint (no default value) +* `accountName` (string) - name of the used account (no default value) +* `accountKey` (string) - base64 encoded account key (no default value) + +Underlying HTTP request related (passed without changes to request module): + +* `timeout` (int) - request timeout in miliseconds (default: 10000) +* `proxy` (string) - proxy URL +* `forever` (bool) - use true to turn advanced socket reuse +* `agentOptions` (object) - used to set maxSockets for forever or standard agent +* `pool` (false|object) - use false to turn off socket reuse + +Azure Table Storage related: + +* `metadata` (string) - default metadata level, available values: `no`, `minimal`, `full` (default: `no`) +* `returnInserts` (bool) - set to true to get back inserted content (usable if etag is needed) + +API +=== + +If not explained differently, `cb` in API is a functions in format function cb(err, data). For queries there may be additional third argument passed if there is a continuation token. + +## Module level + +### getDefaultClient() + +Returns default `Client` object. If `setDefaultClient()` was not used earlier the client only have default module settings and environment variable applied. + +### setDefaultClient(settings) + +Sets up and returns default `Client` object with provided `settings`. It is using default settings as a base. + +### createClient(settings, [base]) + +Returns new `Client` object using new settings and `base` client settings as a fallback. If `base` is not provided, uses default client settings. + +## Client object level + +### create(settings) + +Returns new `Client` object using only provided settings object. Shouldn't be used directly unless you want to provide all options. Use `createClient` from main module if possible. + +### getSettings() + +Returns sealed settings object used by this client. + +### createTable(table, [options], cb) + +Creates new table. The `table` is table name. The `options` is optional, but if exists and `ignoreIfExists` key equals `true`, the error 'table already exists' is ignored. The `cb` is a standard callback function. + +### deleteTable(table, cb) + +Removes existing table. The `table` is table name. The `cb` is a standard callback function. + +### listTables([options], cb) + +Returns array with table names (as strings). The `options` is optional, but if exists and `nextTableName` key is provided, the retrieval will start from last continuation token. The `cb` is a standard callback function, but if continuation is required, the third argument will be passed with value for `nextTableName` key. -It is a work in progress. Not yet usable in any way. diff --git a/index.js b/index.js index ad9a93a..54a1120 100644 --- a/index.js +++ b/index.js @@ -1 +1,69 @@ 'use strict'; + +var _ = require('lodash'); +var utils = require('./lib/utils'); +var Client = require('./lib/client').Client; + +var _defaultClientSetting = { + timeout: 10000, + metadata: 'no' +}; + +// default client is created lazily on first get or set request +var _defaultClient = null; + +// initializes default client using default settings and environment variable CLOUD_STORAGE_ACCOUNT +function _initDefaultConnection() { + if ('CLOUD_STORAGE_ACCOUNT' in process.env) { + var accountSettings = utils.parseAccountString(process.env.CLOUD_STORAGE_ACCOUNT); + if (accountSettings !== null) { + _defaultClientSetting = _.defaults(_defaultClientSetting, accountSettings); + } + } +} + +function getDefaultClient() { + if (_defaultClient === null) { + _defaultClient = Client.create(_defaultClientSetting); + } + return _defaultClient; +} + +function setDefaultClient(settings) { + _defaultClient = createClient(settings); + return _defaultClient; +} + +function createClient(settings, base) { + var baseSettings; + if (base) { + baseSettings = base.getSettings(); + } else if (_defaultClient !== null) { + baseSettings = _defaultClient.getSettings(); + } else { + baseSettings = _defaultClientSetting; + } + + var finalSettings = _.clone(baseSettings); + if (settings) { + finalSettings = _.merge(finalSettings, settings, function(a, b) { + return _.isArray(a) ? a.concat(b) : undefined; + }); + } + + return Client.create(finalSettings); +} + + +var azureTable = { + // () -> Client object + getDefaultClient: getDefaultClient, + // (options{object}) -> Client object + setDefaultClient: setDefaultClient, + // (options{object}, [base{object}]) -> Client object + createClient: createClient +}; + +_initDefaultConnection(); + +module.exports = azureTable; \ No newline at end of file diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..a68aa91 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,206 @@ +'use strict'; + +var request = require('request'); +var url = require('url'); + +var versionInfo = require('../package.json').version; +var utils = require('./utils'); + +var Client = { + // settings object, cannot be edited + _settings: null, + // request object with defaults for this client + _request: null, + // decoded azure key + _azureKey: null, + + _prepareRequestDefaults: function(settings) { + var defaults = { + encoding: 'utf8', + timeout: settings.timeout + }; + if (settings.proxy) { + defaults.proxy = settings.proxy; + } + if (settings.forever === true) { + defaults.forever = settings.forever; + } + if (settings.agentOptions) { + defaults.agentOptions = settings.agentOptions; + } + if (settings.pool != null) { + defaults.pool = settings.pool; + } + return defaults; + }, + + _getRequestSpecificOptions: function _getRequestSpecificOptions(method, path, qs) { + var now = new Date().toUTCString(); + + var requestOptions = { + method: method, + uri: url.parse(this._settings.accountUrl + path), + qs: qs, + headers: { + accept: 'application/json;odata='+this._settings.metadata+'metadata', + DataServiceVersion: '3.0;NetFx', + date: now, + prefer: this._settings.returnInserts === true ? 'return-content' : 'return-no-content', + 'user-agent': 'azure-table-node/'+versionInfo, + 'x-ms-date': now, + 'x-ms-version': '2013-08-15' + } + }; + + // json key will add it, be we need it for signing header computation + if (method !== 'GET' && method !== 'DELETE') { + requestOptions.headers['content-type'] = 'application/json'; + } + + return requestOptions; + }, + + _addSharedKeyAuthHeader: function _addSharedKeyAuthHeader(requestOptions) { + var stringToSign = requestOptions.method +'\n'; + stringToSign += (requestOptions.headers['content-md5'] ? requestOptions.headers['content-md5'] : '') + '\n'; + stringToSign += (requestOptions.headers['content-type'] ? requestOptions.headers['content-type'] : '') + '\n'; + stringToSign += (requestOptions.headers['x-ms-date'] ? requestOptions.headers['x-ms-date'] : '') + '\n'; + stringToSign += '/'+this._settings.accountName; + stringToSign += requestOptions.uri.path; + if (requestOptions.qs && 'comp' in requestOptions.qs) { + stringToSign += '?comp=' + requestOptions.qs.comp; + } + + requestOptions.headers.authorization = 'SharedKey ' + this._settings.accountName + ':' + utils.hmacSha256(this._azureKey, stringToSign); + return requestOptions; + }, + + _normalizeCallback: function _normalizeCallback(cb, error, response, body) { + if (error) { + return cb(error); + } + if (!response) { + return cb({code: 'UnknownError'}); + } + // try to parse to JSON if it looks like JSON but is not + if (body && typeof body === 'string' &&(body[0] === '{' || body[0] === '[')) { + try { + body = JSON.parse(body); + } catch (e) {} + } + if (response.statusCode >= 400) { + return cb({ + statusCode: response.statusCode, + code: body && body['odata.error'] ? body['odata.error'].code : 'UnknownBody', + body: body && body['odata.error'] ? body['odata.error'] : body + }); + } + return cb(null, { + statusCode: response.statusCode, + headers: response.headers, // continuations are in response headers + body: body + }); + }, + + _makeRequest: function _makeRequest(method, path, qs, body, filter, cb) { + if (cb == null) { + cb = filter; + } + var options = this._getRequestSpecificOptions(method, path, qs); + options = this._addSharedKeyAuthHeader(options); + + if (typeof body === 'object') { + options.json = body; + } + + if (cb !== filter) { + options = filter(options); + } + + console.log('Options', options); + // TODO: possible place for retry logic + this._request(options, this._normalizeCallback.bind(this, cb)); + }, + + create: function create(settings) { + if (!settings.accountUrl || !settings.accountName || !settings.accountKey) { + throw 'Provide accountUrl, accountName, and accountKey in settings or in env CLOUD_STORAGE_ACCOUNT'; + } + + var sealedSettings = Object.seal(settings); + + // create request object with most of the default settings + var defaultRequest = request.defaults(this._prepareRequestDefaults(sealedSettings)); + + return Object.create(this, { + _settings: {value: sealedSettings}, + _request: {value: defaultRequest}, + _azureKey: {value: utils.base64Decode(sealedSettings.accountKey)} + }); + }, + + getSettings: function getSettings() { + return this._settings; + }, + + _createTableCb: function _createTableCb(cb, options, err, data) { + if (!err && (data.statusCode === 201 || data.statusCode === 204)) { + return cb(null, undefined); + } else if (options && options.ignoreIfExists === true && err && err.code === 'TableAlreadyExists') { + return cb(null, undefined); + } else { + return cb(err, null); + } + }, + createTable: function createTable(table, options, cb) { + if (cb == null) { + cb = options; + } + this._makeRequest('POST', 'Tables', null, {TableName:table}, this._createTableCb.bind(this, cb, options !== cb ? options : null)); + return this; + }, + + _deleteTableCb: function _deleteTableCb(cb, err, data) { + if (!err && data.statusCode === 204) { + return cb(null, undefined); + } else { + return cb(err, null); + } + }, + deleteTable: function deleteTable(table, options, cb) { + if (cb == null) { + cb = options; + } + this._makeRequest('DELETE', 'Tables(\''+table+'\')', null, null, this._deleteTableCb.bind(this, cb)); + return this; + }, + + _listTablesCb: function _listTablesCb(cb, err, data) { + if (!err && data.statusCode === 200) { + var results = new Array(data.body.value.length); + data.body.value.forEach(function(r, i) { + this[i] = r.TableName; + }, results); + var continuation = data.headers['x-ms-continuation-nexttablename']; + return cb(null, results, continuation); + } else { + return cb(err, null); + } + }, + listTables: function listTables(options, cb){ + if (cb == null) { + cb = options; + } + var qs = null; + if (cb !== options && options.nextTableName) { + qs = { + NextTableName: options.nextTableName + }; + } + + this._makeRequest('GET', 'Tables', qs, null, this._listTablesCb.bind(this, cb)); + return this; + } +}; + +exports.Client = Client; \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..18864d2 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,59 @@ +'use strict'; + +var crypto = require('crypto'); + +var ACCOUNT_STRING_KEYS = ['TableEndpoint', 'AccountName', 'AccountKey']; +var ACCOUNT_STRING_SETTINGS_KEYS = ['accountUrl', 'accountName', 'accountKey']; + +exports.parseAccountString = function parseAccountString(accountString) { + if (typeof accountString !== 'string') { + return null; + } + + var trimmedAS = accountString.trim(); + if (trimmedAS.length < 30) { + return null; + } + + var splittedAS = trimmedAS.split(';'); + if (splittedAS.length < 3) { + return null; + } + + var entry, i, j, retrievedValues = {}; + for (i = 0; i < splittedAS.length; ++i) { + entry = splittedAS[i].split('='); + if (entry.length < 2) { + return null; + } + // get back if within string + if (entry.length > 2) { + for (j = 2; j < entry.length; ++j) { + entry[1] += '='+entry[j]; + } + } + if (ACCOUNT_STRING_KEYS.indexOf(entry[0]) !== -1) { + retrievedValues[entry[0]] = entry[1]; + } + } + + if (Object.keys(retrievedValues).length !== ACCOUNT_STRING_KEYS.length) { + return null; + } + + // convert to settings keys + var finalValues = {}; + for (i = 0; i < ACCOUNT_STRING_SETTINGS_KEYS.length; ++i) { + finalValues[ACCOUNT_STRING_SETTINGS_KEYS[i]] = retrievedValues[ACCOUNT_STRING_KEYS[i]]; + } + + return finalValues; +}; + +exports.base64Decode = function base64Decode(base64String) { + return new Buffer(base64String, 'base64'); +}; + +exports.hmacSha256 = function hmacSha256(keyBuffer, stringToSign) { + return crypto.createHmac('sha256', keyBuffer).update(stringToSign).digest('base64'); +}; diff --git a/package.json b/package.json index 6e2b9c4..507eaa5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "table", "client" ], - "author": "Gluwer Rafał Jońca", + "author": { + "name": "Gluwer Rafał Jońca", + "email": "rafal@gluwer.com" + }, "license": "MIT", "bugs": { "url": "https://github.com/gluwer/azure-table-node/issues" @@ -27,6 +30,14 @@ "lib": "./lib" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.8.26" + }, + "dependencies": { + "request": "~2.31.0", + "lodash": "~2.4.1" + }, + "devDependencies": { + "mocha": "~1.17.0", + "chai": "~1.8.1" } } diff --git a/test/all.js b/test/all.js index ad9a93a..e6d8b9f 100644 --- a/test/all.js +++ b/test/all.js @@ -1 +1,33 @@ 'use strict'; + +var Mocha = require('mocha'); + +// mocha options +var options = {}; +var suites = []; + +// run each suite independently to allow to run each one manually using mocha test/{test}.js +suites.push((new Mocha(options)).addFile('test/test-parseAccountString.js')); +suites.push((new Mocha(options)).addFile('test/test-defaultClient.js')); +suites.push((new Mocha(options)).addFile('test/test-createClient.js')); + +var currentSuite = 0; + +var failedTests = false; +function runSuites() { + if (currentSuite < suites.length) { + suites[currentSuite].run(function(failures) { + if (failures) { + failedTests = true; + } + currentSuite += 1; + setImmediate(runSuites); + }); + } else { + if (failedTests) { + process.exit(1); + } + } +} +runSuites(); + diff --git a/test/test-createClient.js b/test/test-createClient.js new file mode 100644 index 0000000..848ac08 --- /dev/null +++ b/test/test-createClient.js @@ -0,0 +1,70 @@ +/* jshint expr: true */ +/* globals it, describe, before, after */ +'use strict'; + +var expect = require('chai').expect; +var azureTable = require('../index'); + +describe('create client', function() { + it('should throw exception if account settings are not set up and created client doesn\'t define them', function() { + function creatingClient() { + azureTable.createClient(); + } + + expect(creatingClient).to.throw('Provide accountUrl, accountName, and accountKey in settings or in env CLOUD_STORAGE_ACCOUNT'); + }); + + it('should create client with additional settings and not override default settings', function() { + var newClient = azureTable.createClient({ + accountUrl: 'http://dummy.table.core.windows.net/', + accountName: 'dummy', + accountKey: 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==' + }); + + expect(newClient).to.be.an('object'); + + var settings = newClient.getSettings(); + + expect(settings).to.have.property('accountUrl', 'http://dummy.table.core.windows.net/'); + expect(settings).to.have.property('accountName', 'dummy'); + expect(settings).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + expect(settings).to.have.property('timeout', 10000); + }); + + it('should create client with overridden default settings', function() { + var newClient = azureTable.createClient({ + accountUrl: 'https://dummy.table.core.windows.net/', + accountName: 'dummy', + accountKey: 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==', + timeout: 15000 + }); + + expect(newClient).to.be.an('object'); + + var settings = newClient.getSettings(); + + expect(settings).to.have.property('accountUrl', 'https://dummy.table.core.windows.net/'); + expect(settings).to.have.property('accountName', 'dummy'); + expect(settings).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + expect(settings).to.have.property('timeout', 15000); + }); + + it('should create client based on other client', function() { + var baseClient = azureTable.createClient({ + accountUrl: 'http://dummy.table.core.windows.net/', + accountName: 'dummy', + accountKey: 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==' + }); + + var newClient = azureTable.createClient({ + timeout: 15000 + }, baseClient); + + var settings = newClient.getSettings(); + + expect(settings).to.have.property('accountUrl', 'http://dummy.table.core.windows.net/'); + expect(settings).to.have.property('accountName', 'dummy'); + expect(settings).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + expect(settings).to.have.property('timeout', 15000); + }); +}); \ No newline at end of file diff --git a/test/test-defaultClient.js b/test/test-defaultClient.js new file mode 100644 index 0000000..291c0f5 --- /dev/null +++ b/test/test-defaultClient.js @@ -0,0 +1,64 @@ +/* jshint expr: true */ +/* globals it, describe, before, after */ +'use strict'; + +var expect = require('chai').expect; + +describe('default client', function() { + var oldEnvSetting; + before(function() { + oldEnvSetting = process.env.CLOUD_STORAGE_ACCOUNT; + process.env.CLOUD_STORAGE_ACCOUNT = 'TableEndpoint=http://dummy.table.core.windows.net/;AccountName=dummy;AccountKey=XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='; + }); + + after(function() { + if (oldEnvSetting) { + process.env.CLOUD_STORAGE_ACCOUNT = oldEnvSetting; + } else { + delete process.env.CLOUD_STORAGE_ACCOUNT; + } + + // force reloading the index module for next tests + delete require.cache[require.resolve('../index')]; + }); + + it('should be created with storage account settings', function() { + var azureTable = require('../index'); + var defaultClient = azureTable.getDefaultClient(); + + expect(defaultClient).to.be.an('object'); + + var settings = defaultClient.getSettings(); + expect(settings).to.have.property('accountUrl', 'http://dummy.table.core.windows.net/'); + expect(settings).to.have.property('accountName', 'dummy'); + expect(settings).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + expect(settings).to.have.property('timeout', 10000); + }); + + it('should allow to create new default client with overridden settings', function() { + var azureTable = require('../index'); + var newDefaultClient = azureTable.setDefaultClient({ + timeout: 15000, + aSetting: 'HELLO', + accountName: 'zebra' + }); + + expect(newDefaultClient).to.be.an('object'); + + var settings = newDefaultClient.getSettings(); + expect(settings).to.have.property('accountUrl', 'http://dummy.table.core.windows.net/'); + expect(settings).to.have.property('accountName', 'zebra'); + expect(settings).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + expect(settings).to.have.property('timeout', 15000); + expect(settings).to.have.property('aSetting', 'HELLO'); + }); + + it('should use default client as singleton', function() { + var azureTable = require('../index'); + var defaultClient1 = azureTable.getDefaultClient(); + var defaultClient2 = azureTable.getDefaultClient(); + + expect(defaultClient1).to.equal(defaultClient2); + }); + +}); \ No newline at end of file diff --git a/test/test-parseAccountString.js b/test/test-parseAccountString.js new file mode 100644 index 0000000..a202a92 --- /dev/null +++ b/test/test-parseAccountString.js @@ -0,0 +1,36 @@ +/* jshint expr: true */ +/* globals it, describe */ +'use strict'; + +var expect = require('chai').expect; +var parseAccountString = require('../lib/utils').parseAccountString; + +describe('parseAccountString', function() { + it('should return account info for properly constructed account string', function() { + var returnedValue; + + // the most common version used on azure + returnedValue = parseAccountString('BlobEndpoint=http://dummy.blob.core.windows.net/;QueueEndpoint=http://dummy.queue.core.windows.net/;TableEndpoint=http://dummy.table.core.windows.net/;AccountName=dummy;AccountKey=XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + + expect(returnedValue).to.not.be.null; + expect(returnedValue).to.have.property('accountUrl', 'http://dummy.table.core.windows.net/'); + expect(returnedValue).to.have.property('accountName', 'dummy'); + expect(returnedValue).to.have.property('accountKey', 'XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg=='); + + // minimalistic (only required data) + expect(parseAccountString('AccountName=dummy;TableEndpoint=http://dummy.table.core.windows.net/;AccountKey=XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==')).to.be.deep.equal(returnedValue); + }); + + it('should return null for incomplete account string', function() { + expect(parseAccountString('TableEndpoint=http://dummy.table.core.windows.net/;AccountKey=XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==')).to.be.null; + expect(parseAccountString('TableEndpoint=http://dummy.table.core.windows.net/;AccountName=dummy')).to.be.null; + expect(parseAccountString('AccountName=dummy;AccountKey=XUpVW5efmPDA42r4VY/86bt9k+smnhdEFVRRGrrt/wE0SmFg==')).to.be.null; + }); + + it('should return null for invalid or not existing account string', function() { + expect(parseAccountString(undefined)).to.be.null; + expect(parseAccountString(null)).to.be.null; + expect(parseAccountString('')).to.be.null; + expect(parseAccountString(' Key=value ')).to.be.null; + }); +}); \ No newline at end of file