From a2a54c49b1a03c26d4321124c99dceab95ad9660 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Fri, 24 Nov 2023 11:11:38 +0100 Subject: [PATCH 1/7] fix: scope in body responses is always a string --- lib/handlers/token-handler.js | 6 ++ lib/utils/scope-util.js | 19 ++-- lib/validator/is.js | 90 ------------------- test/compliance/client-authentication_test.js | 4 +- .../client-credential-workflow_test.js | 2 +- test/compliance/password-grant-type_test.js | 2 +- .../refresh-token-grant-type_test.js | 4 +- test/helpers/model.js | 14 ++- .../handlers/token-handler_test.js | 10 +-- 9 files changed, 44 insertions(+), 107 deletions(-) delete mode 100644 lib/validator/is.js diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js index 6ce6c215..33c70ec1 100644 --- a/lib/handlers/token-handler.js +++ b/lib/handlers/token-handler.js @@ -266,6 +266,12 @@ class TokenHandler { updateSuccessResponse (response, tokenType) { response.body = tokenType.valueOf(); + // for compliance reasons we rebuild the internal scope to be a string + // https://datatracker.ietf.org/doc/html/rfc6749.html#section-5.1 + if (response.body.scope) { + response.body.scope = response.body.scope.join(' '); + } + response.set('Cache-Control', 'no-store'); response.set('Pragma', 'no-cache'); } diff --git a/lib/utils/scope-util.js b/lib/utils/scope-util.js index 61278587..51d9702f 100644 --- a/lib/utils/scope-util.js +++ b/lib/utils/scope-util.js @@ -1,16 +1,25 @@ const isFormat = require('@node-oauth/formats'); const InvalidScopeError = require('../errors/invalid-scope-error'); +const toArray = s => Array.isArray(s) ? s : s.split(' '); module.exports = { parseScope: function (requestedScope) { - if (!isFormat.nqschar(requestedScope)) { - throw new InvalidScopeError('Invalid parameter: `scope`'); + if (typeof requestedScope === 'undefined' || requestedScope === null) { + return undefined; } - if (requestedScope == null) { - return undefined; + const internalScope = toArray(requestedScope); + + if (internalScope.length === 0) { + throw new InvalidScopeError('Invalid parameter: `scope`'); } - return requestedScope.split(' '); + internalScope.forEach(value => { + if (!isFormat.nqschar(value)) { + throw new InvalidScopeError('Invalid parameter: `scope`'); + } + }); + + return internalScope; } }; diff --git a/lib/validator/is.js b/lib/validator/is.js deleted file mode 100644 index 4bb58c55..00000000 --- a/lib/validator/is.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; -/* eslint-disable no-control-regex */ - -/** - * Validation rules. - */ - -const rules = { - NCHAR: /^[\u002D\u002E\u005F\w]+$/, - NQCHAR: /^[\u0021\u0023-\u005B\u005D-\u007E]+$/, - NQSCHAR: /^[\u0020-\u0021\u0023-\u005B\u005D-\u007E]+$/, - UNICODECHARNOCRLF: /^[\u0009\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD]+$/, - UNICODECHARNOCRLF_EXTENDED: /^[\u{10000}-\u{10FFFF}]+$/u, - URI: /^[a-zA-Z][a-zA-Z0-9+.-]+:/, - VSCHAR: /^[\u0020-\u007E]+$/ -}; - -/* eslint-enable no-control-regex */ - -/** - * Export validation functions. - */ - -module.exports = { - - /** - * Validate if a value matches a unicode character. - * - * @see https://tools.ietf.org/html/rfc6749#appendix-A - */ - - nchar: function(value) { - return rules.NCHAR.test(value); - }, - - /** - * Validate if a value matches a unicode character, including exclamation marks. - * - * @see https://tools.ietf.org/html/rfc6749#appendix-A - */ - - nqchar: function(value) { - return rules.NQCHAR.test(value); - }, - - /** - * Validate if a value matches a unicode character, including exclamation marks and spaces. - * - * @see https://tools.ietf.org/html/rfc6749#appendix-A - */ - - nqschar: function(value) { - return rules.NQSCHAR.test(value); - }, - - /** - * Validate if a value matches a unicode character excluding the carriage - * return and linefeed characters. - * - * @see https://tools.ietf.org/html/rfc6749#appendix-A - */ - - uchar: function(value) { - // manually test \u10000-\u10FFFF - if (rules.UNICODECHARNOCRLF.test(value)) { - return true; - } - - return rules.UNICODECHARNOCRLF_EXTENDED.test(value); - }, - - /** - * Validate if a value matches generic URIs. - * - * @see http://tools.ietf.org/html/rfc3986#section-3 - */ - uri: function(value) { - return rules.URI.test(value); - }, - - /** - * Validate if a value matches against the printable set of unicode characters. - * - * @see https://tools.ietf.org/html/rfc6749#appendix-A - */ - - vschar: function(value) { - return rules.VSCHAR.test(value); - } -}; diff --git a/test/compliance/client-authentication_test.js b/test/compliance/client-authentication_test.js index 72624ec5..a214dbfc 100644 --- a/test/compliance/client-authentication_test.js +++ b/test/compliance/client-authentication_test.js @@ -37,12 +37,14 @@ const client = db.saveClient({ id: 'a', secret: 'b', grants: ['password'] }); const scope = 'read write'; function createDefaultRequest () { + const dice = Math.random() > 0.5; + const currentScope = dice ? scope : scope.split(' '); return createRequest({ body: { grant_type: 'password', username: user.username, password: user.password, - scope + scope: currentScope }, headers: { 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), diff --git a/test/compliance/client-credential-workflow_test.js b/test/compliance/client-credential-workflow_test.js index 5e71d4ab..6c63c255 100644 --- a/test/compliance/client-credential-workflow_test.js +++ b/test/compliance/client-credential-workflow_test.js @@ -90,7 +90,7 @@ describe('ClientCredentials Workflow Compliance (4.4)', function () { response.body.token_type.should.equal('Bearer'); response.body.access_token.should.equal(token.accessToken); response.body.expires_in.should.be.a('number'); - response.body.scope.should.eql(['read', 'write']); + response.body.scope.should.eql('read write'); ('refresh_token' in response.body).should.equal(false); token.accessToken.should.be.a('string'); diff --git a/test/compliance/password-grant-type_test.js b/test/compliance/password-grant-type_test.js index c30e440d..d82193af 100644 --- a/test/compliance/password-grant-type_test.js +++ b/test/compliance/password-grant-type_test.js @@ -101,7 +101,7 @@ describe('PasswordGrantType Compliance', function () { response.body.access_token.should.equal(token.accessToken); response.body.refresh_token.should.equal(token.refreshToken); response.body.expires_in.should.be.a('number'); - response.body.scope.should.eql(['read', 'write']); + response.body.scope.should.eql('read write'); token.accessToken.should.be.a('string'); token.refreshToken.should.be.a('string'); diff --git a/test/compliance/refresh-token-grant-type_test.js b/test/compliance/refresh-token-grant-type_test.js index 09427855..fd3d40da 100644 --- a/test/compliance/refresh-token-grant-type_test.js +++ b/test/compliance/refresh-token-grant-type_test.js @@ -124,7 +124,7 @@ describe('RefreshTokenGrantType Compliance', function () { refreshResponse.body.access_token.should.equal(token.accessToken); refreshResponse.body.refresh_token.should.equal(token.refreshToken); refreshResponse.body.expires_in.should.be.a('number'); - refreshResponse.body.scope.should.eql(['read', 'write']); + refreshResponse.body.scope.should.eql('read write'); token.accessToken.should.be.a('string'); token.refreshToken.should.be.a('string'); @@ -223,7 +223,7 @@ describe('RefreshTokenGrantType Compliance', function () { refreshResponse.body.access_token.should.equal(token.accessToken); refreshResponse.body.refresh_token.should.equal(token.refreshToken); refreshResponse.body.expires_in.should.be.a('number'); - refreshResponse.body.scope.should.eql(['read']); + refreshResponse.body.scope.should.eql('read'); }); }); }); diff --git a/test/helpers/model.js b/test/helpers/model.js index 6566f0cd..4efe591f 100644 --- a/test/helpers/model.js +++ b/test/helpers/model.js @@ -10,6 +10,9 @@ function createModel (db) { } async function saveToken (token, client, user) { + if (token.scope && !Array.isArray(token.scope)) { + throw new Error('Scope should internally be an array'); + } const meta = { clientId: client.id, userId: user.id, @@ -38,7 +41,9 @@ function createModel (db) { if (!meta) { return false; } - + if (meta.scope && !Array.isArray(meta.scope)) { + throw new Error('Scope should internally be an array'); + } return { accessToken, accessTokenExpiresAt: meta.accessTokenExpiresAt, @@ -54,7 +59,9 @@ function createModel (db) { if (!meta) { return false; } - + if (meta.scope && !Array.isArray(meta.scope)) { + throw new Error('Scope should internally be an array'); + } return { refreshToken, refreshTokenExpiresAt: meta.refreshTokenExpiresAt, @@ -71,6 +78,9 @@ function createModel (db) { } async function verifyScope (token, scope) { + if (!Array.isArray(scope)) { + throw new Error('Scope should internally be an array'); + } return scope.every(s => scopes.includes(s)); } diff --git a/test/integration/handlers/token-handler_test.js b/test/integration/handlers/token-handler_test.js index 1c2db3b4..222fe65d 100644 --- a/test/integration/handlers/token-handler_test.js +++ b/test/integration/handlers/token-handler_test.js @@ -329,7 +329,7 @@ describe('TokenHandler integration', function() { }); it('should not return custom attributes in a bearer token if the allowExtendedTokenAttributes is not set', function() { - const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['foobar'], user: {}, foo: 'bar' }; + const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['baz'], user: {}, foo: 'bar' }; const model = { getClient: function() { return { grants: ['password'] }; }, getUser: function() { return {}; }, @@ -344,7 +344,7 @@ describe('TokenHandler integration', function() { username: 'foo', password: 'bar', grant_type: 'password', - scope: 'baz' + scope: ['baz'] }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', @@ -357,14 +357,14 @@ describe('TokenHandler integration', function() { should.exist(response.body.access_token); should.exist(response.body.refresh_token); should.exist(response.body.token_type); - should.exist(response.body.scope); + response.body.scope.should.eql('baz'); should.not.exist(response.body.foo); }) .catch(should.fail); }); it('should return custom attributes in a bearer token if the allowExtendedTokenAttributes is set', function() { - const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['foobar'], user: {}, foo: 'bar' }; + const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['baz'], user: {}, foo: 'bar' }; const model = { getClient: function() { return { grants: ['password'] }; }, getUser: function() { return {}; }, @@ -392,7 +392,7 @@ describe('TokenHandler integration', function() { should.exist(response.body.access_token); should.exist(response.body.refresh_token); should.exist(response.body.token_type); - should.exist(response.body.scope); + response.body.scope.should.eql('baz'); should.exist(response.body.foo); }) .catch(should.fail); From 687b66e8cdc298cd260802b86ba4e2a47cfd0e98 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Fri, 24 Nov 2023 13:07:31 +0100 Subject: [PATCH 2/7] fix: revert scope util to omit empty-string scope values --- lib/utils/scope-util.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/lib/utils/scope-util.js b/lib/utils/scope-util.js index 51d9702f..fe60a9f8 100644 --- a/lib/utils/scope-util.js +++ b/lib/utils/scope-util.js @@ -1,25 +1,16 @@ const isFormat = require('@node-oauth/formats'); const InvalidScopeError = require('../errors/invalid-scope-error'); -const toArray = s => Array.isArray(s) ? s : s.split(' '); module.exports = { parseScope: function (requestedScope) { - if (typeof requestedScope === 'undefined' || requestedScope === null) { - return undefined; - } - - const internalScope = toArray(requestedScope); - - if (internalScope.length === 0) { + if (!isFormat.nqschar(requestedScope)) { throw new InvalidScopeError('Invalid parameter: `scope`'); } - internalScope.forEach(value => { - if (!isFormat.nqschar(value)) { - throw new InvalidScopeError('Invalid parameter: `scope`'); - } - }); + if (requestedScope == null) { + return undefined; + } - return internalScope; + return Array.isArray(requestedScope) ? requestedScope : requestedScope.split(' '); } }; From d5ae484a41d665eb52ae4c0f969db4dc22a8d17b Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Fri, 24 Nov 2023 14:27:20 +0100 Subject: [PATCH 3/7] tests: remove randomness from compliance tests --- test/compliance/client-authentication_test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/compliance/client-authentication_test.js b/test/compliance/client-authentication_test.js index a214dbfc..72624ec5 100644 --- a/test/compliance/client-authentication_test.js +++ b/test/compliance/client-authentication_test.js @@ -37,14 +37,12 @@ const client = db.saveClient({ id: 'a', secret: 'b', grants: ['password'] }); const scope = 'read write'; function createDefaultRequest () { - const dice = Math.random() > 0.5; - const currentScope = dice ? scope : scope.split(' '); return createRequest({ body: { grant_type: 'password', username: user.username, password: user.password, - scope: currentScope + scope }, headers: { 'authorization': 'Basic ' + Buffer.from(client.id + ':' + client.secret).toString('base64'), From 0d98453871334180c5eed11de512c154f2025c61 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 27 Nov 2023 13:01:39 +0100 Subject: [PATCH 4/7] fix: parseScope rejects Array values --- lib/utils/scope-util.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/utils/scope-util.js b/lib/utils/scope-util.js index fe60a9f8..e06a1573 100644 --- a/lib/utils/scope-util.js +++ b/lib/utils/scope-util.js @@ -3,7 +3,9 @@ const InvalidScopeError = require('../errors/invalid-scope-error'); module.exports = { parseScope: function (requestedScope) { - if (!isFormat.nqschar(requestedScope)) { + // XXX: isFormat.nqschar will trat Arrays of strings like String, + // thus we additionally check, whether incoming scopes are Arrays + if (!isFormat.nqschar(requestedScope) || Array.isArray(requestedScope)) { throw new InvalidScopeError('Invalid parameter: `scope`'); } @@ -11,6 +13,6 @@ module.exports = { return undefined; } - return Array.isArray(requestedScope) ? requestedScope : requestedScope.split(' '); + return requestedScope.split(' '); } }; From 960a962364d0792b08fc2cc6f914875c48821f99 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 27 Nov 2023 13:03:53 +0100 Subject: [PATCH 5/7] tests: reverse requests with scopes as Arrays --- test/integration/handlers/token-handler_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/handlers/token-handler_test.js b/test/integration/handlers/token-handler_test.js index 222fe65d..53c9c40f 100644 --- a/test/integration/handlers/token-handler_test.js +++ b/test/integration/handlers/token-handler_test.js @@ -344,7 +344,7 @@ describe('TokenHandler integration', function() { username: 'foo', password: 'bar', grant_type: 'password', - scope: ['baz'] + scope: 'baz' }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', From fcb567be659eb78c2548b28cde25a69567a9757b Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 27 Nov 2023 13:04:14 +0100 Subject: [PATCH 6/7] fix: parse scope in AuthenticateHandler impl --- lib/handlers/authenticate-handler.js | 3 ++- .../handlers/authenticate-handler_test.js | 14 +++++++------- test/unit/handlers/authenticate-handler_test.js | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/handlers/authenticate-handler.js b/lib/handlers/authenticate-handler.js index 32251758..a930c7ca 100644 --- a/lib/handlers/authenticate-handler.js +++ b/lib/handlers/authenticate-handler.js @@ -13,6 +13,7 @@ const Request = require('../request'); const Response = require('../response'); const ServerError = require('../errors/server-error'); const UnauthorizedRequestError = require('../errors/unauthorized-request-error'); +const { parseScope } = require('../utils/scope-util'); /** * Constructor. @@ -46,7 +47,7 @@ class AuthenticateHandler { this.addAuthorizedScopesHeader = options.addAuthorizedScopesHeader; this.allowBearerTokensInQueryString = options.allowBearerTokensInQueryString; this.model = options.model; - this.scope = options.scope; + this.scope = parseScope(options.scope); } /** diff --git a/test/integration/handlers/authenticate-handler_test.js b/test/integration/handlers/authenticate-handler_test.js index 52355550..8684ce80 100644 --- a/test/integration/handlers/authenticate-handler_test.js +++ b/test/integration/handlers/authenticate-handler_test.js @@ -93,7 +93,7 @@ describe('AuthenticateHandler integration', function() { addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, - scope: ['foobar'] + scope: 'foobar' }); grantType.scope.should.eql(['foobar']); @@ -254,7 +254,7 @@ describe('AuthenticateHandler integration', function() { return true; } }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' }); const request = new Request({ body: {}, headers: { 'Authorization': 'Bearer foo' }, @@ -522,7 +522,7 @@ describe('AuthenticateHandler integration', function() { return false; } }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' }); return handler.verifyScope(['foo']) .then(should.fail) @@ -539,7 +539,7 @@ describe('AuthenticateHandler integration', function() { return true; } }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' }); handler.verifyScope(['foo']).should.be.an.instanceOf(Promise); }); @@ -551,7 +551,7 @@ describe('AuthenticateHandler integration', function() { return true; } }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' }); handler.verifyScope(['foo']).should.be.an.instanceOf(Promise); }); @@ -576,7 +576,7 @@ describe('AuthenticateHandler integration', function() { getAccessToken: function() {}, verifyScope: function() {} }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: false, model: model, scope: ['foo', 'bar'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: false, model: model, scope: 'foo bar' }); const response = new Response({ body: {}, headers: {} }); handler.updateResponse(response, { scope: ['foo', 'biz'] }); @@ -602,7 +602,7 @@ describe('AuthenticateHandler integration', function() { getAccessToken: function() {}, verifyScope: function() {} }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: false, addAuthorizedScopesHeader: true, model: model, scope: ['foo', 'bar'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: false, addAuthorizedScopesHeader: true, model: model, scope: 'foo bar' }); const response = new Response({ body: {}, headers: {} }); handler.updateResponse(response, { scope: ['foo', 'biz'] }); diff --git a/test/unit/handlers/authenticate-handler_test.js b/test/unit/handlers/authenticate-handler_test.js index c8433057..6919718f 100644 --- a/test/unit/handlers/authenticate-handler_test.js +++ b/test/unit/handlers/authenticate-handler_test.js @@ -166,7 +166,7 @@ describe('AuthenticateHandler', function() { getAccessToken: function() {}, verifyScope: sinon.stub().returns(true) }; - const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['bar'] }); + const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'bar' }); return handler.verifyScope(['foo']) .then(function() { From 77d00b218ac8a7656a808975ad6f187fe4a3ff46 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 27 Nov 2023 14:36:40 +0100 Subject: [PATCH 7/7] fix: improved parsing of requested scopes --- lib/utils/scope-util.js | 19 +++++++++---- test/unit/utils/scope-util_test.js | 45 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 test/unit/utils/scope-util_test.js diff --git a/lib/utils/scope-util.js b/lib/utils/scope-util.js index e06a1573..a067fdc0 100644 --- a/lib/utils/scope-util.js +++ b/lib/utils/scope-util.js @@ -1,18 +1,25 @@ const isFormat = require('@node-oauth/formats'); const InvalidScopeError = require('../errors/invalid-scope-error'); +const whiteSpace = /\s+/g; module.exports = { parseScope: function (requestedScope) { - // XXX: isFormat.nqschar will trat Arrays of strings like String, - // thus we additionally check, whether incoming scopes are Arrays - if (!isFormat.nqschar(requestedScope) || Array.isArray(requestedScope)) { + if (requestedScope == null) { + return undefined; + } + + if (typeof requestedScope !== 'string') { throw new InvalidScopeError('Invalid parameter: `scope`'); } - if (requestedScope == null) { - return undefined; + // XXX: this prevents spaced-only strings to become + // treated as valid nqchar by making them empty strings + requestedScope = requestedScope.trim(); + + if(!isFormat.nqschar(requestedScope)) { + throw new InvalidScopeError('Invalid parameter: `scope`'); } - return requestedScope.split(' '); + return requestedScope.split(whiteSpace); } }; diff --git a/test/unit/utils/scope-util_test.js b/test/unit/utils/scope-util_test.js new file mode 100644 index 00000000..505262f5 --- /dev/null +++ b/test/unit/utils/scope-util_test.js @@ -0,0 +1,45 @@ +const { parseScope } = require('../../../lib/utils/scope-util'); +const should = require('chai').should(); + +describe(parseScope.name, () => { + it('should return undefined on nullish values', () => { + const values = [undefined, null]; + values.forEach(str => { + const compare = parseScope(str) === undefined; + compare.should.equal(true); + }); + }); + it('should throw on non-string values', () => { + const invalid = [1, -1, true, false, {}, ['foo'], [], () => {}, Symbol('foo')]; + invalid.forEach(str => { + try { + parseScope(str); + should.fail(); + } catch (e) { + e.message.should.eql('Invalid parameter: `scope`'); + } + }); + }); + it('should throw on empty strings', () => { + const invalid = ['', ' ', ' ', '\n', '\t', '\r']; + invalid.forEach(str => { + try { + parseScope(str); + should.fail(); + } catch (e) { + e.message.should.eql('Invalid parameter: `scope`'); + } + }); + }); + it('should split space-delimited strings into arrays', () => { + const values = [ + ['foo', ['foo']], + ['foo bar', ['foo', 'bar']], + ['foo bar', ['foo', 'bar']], + ]; + values.forEach(([str, compare]) => { + const parsed = parseScope(str); + parsed.should.deep.equal(compare); + }); + }); +}); \ No newline at end of file