diff --git a/CHANGELOG.md b/CHANGELOG.md
index 361733ba..a5c80e99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
+## v6.10.2 - 2017-11-27
+
+* Inline the entire resin-cli-auth module #721 [Tim Perry]
+
## v6.10.1 - 2017-11-27
* Set up TypeScript compilation, and make a small start on converting the CLI #720 [Tim Perry]
diff --git a/gulpfile.coffee b/gulpfile.coffee
index 3aad5a5a..1ce0feb0 100644
--- a/gulpfile.coffee
+++ b/gulpfile.coffee
@@ -2,6 +2,8 @@ path = require('path')
gulp = require('gulp')
coffee = require('gulp-coffee')
coffeelint = require('gulp-coffeelint')
+inlinesource = require('gulp-inline-source')
+mocha = require('gulp-mocha')
shell = require('gulp-shell')
packageJSON = require('./package.json')
@@ -10,10 +12,17 @@ OPTIONS =
coffeelint: path.join(__dirname, 'coffeelint.json')
files:
coffee: [ 'lib/**/*.coffee', 'gulpfile.coffee' ]
- app: [ 'lib/**/*.coffee', '!lib/**/*.spec.coffee' ]
+ app: 'lib/**/*.coffee'
+ tests: 'tests/**/*.spec.coffee'
+ pages: 'lib/auth/pages/*.ejs'
directories:
build: 'build/'
+gulp.task 'pages', ->
+ gulp.src(OPTIONS.files.pages)
+ .pipe(inlinesource())
+ .pipe(gulp.dest('build/auth/pages'))
+
gulp.task 'coffee', [ 'lint' ], ->
gulp.src(OPTIONS.files.app)
.pipe(coffee(bare: true, header: true))
@@ -26,5 +35,16 @@ gulp.task 'lint', ->
}))
.pipe(coffeelint.reporter())
-gulp.task 'watch', [ 'coffee' ], ->
- gulp.watch([ OPTIONS.files.coffee ], [ 'coffee' ])
+gulp.task 'test', ->
+ gulp.src(OPTIONS.files.tests, read: false)
+ .pipe(mocha({
+ reporter: 'min'
+ }))
+
+gulp.task 'build', [
+ 'coffee',
+ 'pages'
+]
+
+gulp.task 'watch', [ 'build' ], ->
+ gulp.watch([ OPTIONS.files.coffee ], [ 'build' ])
diff --git a/lib/actions/auth.coffee b/lib/actions/auth.coffee
index 40b645ae..adcc5aaa 100644
--- a/lib/actions/auth.coffee
+++ b/lib/actions/auth.coffee
@@ -74,7 +74,7 @@ exports.login =
_ = require('lodash')
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
- auth = require('resin-cli-auth')
+ auth = require('../auth')
form = require('resin-cli-form')
patterns = require('../utils/patterns')
messages = require('../utils/messages')
diff --git a/lib/actions/help.coffee b/lib/actions/help.coffee
index 9693e273..45b69788 100644
--- a/lib/actions/help.coffee
+++ b/lib/actions/help.coffee
@@ -15,7 +15,6 @@ limitations under the License.
###
_ = require('lodash')
-_.str = require('underscore.string')
capitano = require('capitano')
columnify = require('columnify')
messages = require('../utils/messages')
@@ -36,7 +35,7 @@ parse = (object) ->
]
indent = (text) ->
- text = _.map _.str.lines(text), (line) ->
+ text = _.map text.split('\n'), (line) ->
return ' ' + line
return text.join('\n')
@@ -92,7 +91,7 @@ command = (params, options, done) ->
if command.help?
console.log("\n#{command.help}")
else if command.description?
- console.log("\n#{_.str.humanize(command.description)}")
+ console.log("\n#{_.capitalize(command.description)}")
if not _.isEmpty(command.options)
console.log('\nOptions:\n')
diff --git a/lib/auth/index.coffee b/lib/auth/index.coffee
new file mode 100644
index 00000000..73889884
--- /dev/null
+++ b/lib/auth/index.coffee
@@ -0,0 +1,63 @@
+###
+Copyright 2016 Resin.io
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+###
+
+###*
+# @module auth
+###
+
+open = require('open')
+resin = require('resin-sdk-preconfigured')
+server = require('./server')
+utils = require('./utils')
+
+###*
+# @summary Login to the Resin CLI using the web dashboard
+# @function
+# @public
+#
+# @description
+# This function opens the user's default browser and points it
+# to the Resin.io dashboard where the session token exchange will
+# take place.
+#
+# Once the the token is retrieved, it's automatically persisted.
+#
+# @fulfil {String} - session token
+# @returns {Promise}
+#
+# @example
+# auth.login().then (sessionToken) ->
+# console.log('I\'m logged in!')
+# console.log("My session token is: #{sessionToken}")
+###
+exports.login = ->
+ options =
+ port: 8989
+ path: '/auth'
+
+ # Needs to be 127.0.0.1 not localhost, because the ip only is whitelisted
+ # from mixed content warnings (as the target of a form in the result page)
+ callbackUrl = "http://127.0.0.1:#{options.port}#{options.path}"
+ return utils.getDashboardLoginURL(callbackUrl).then (loginUrl) ->
+
+ # Leave a bit of time for the
+ # server to get up and runing
+ setTimeout ->
+ open(loginUrl)
+ , 1000
+
+ return server.awaitForToken(options)
+ .tap(resin.auth.loginWithToken)
diff --git a/lib/auth/pages/error.ejs b/lib/auth/pages/error.ejs
new file mode 100644
index 00000000..64915338
--- /dev/null
+++ b/lib/auth/pages/error.ejs
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Resin CLI - Error
+
+
+
+
+
+
+
+
diff --git a/lib/auth/pages/static/images/happy.png b/lib/auth/pages/static/images/happy.png
new file mode 100644
index 00000000..62143f77
Binary files /dev/null and b/lib/auth/pages/static/images/happy.png differ
diff --git a/lib/auth/pages/static/images/sad.png b/lib/auth/pages/static/images/sad.png
new file mode 100644
index 00000000..9fdf949b
Binary files /dev/null and b/lib/auth/pages/static/images/sad.png differ
diff --git a/lib/auth/pages/static/style.css b/lib/auth/pages/static/style.css
new file mode 100644
index 00000000..9b50d08a
--- /dev/null
+++ b/lib/auth/pages/static/style.css
@@ -0,0 +1,60 @@
+html,
+body {
+ height: 100%;
+}
+
+body {
+ text-align: center;
+ background-color: #fff;
+ color: rgb(24, 24, 24);
+ font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
+ position: relative;
+}
+
+.center {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: 50%;
+ height: 50%;
+}
+
+.icon {
+ display: block;
+ width: 40px;
+ height: 45px;
+ margin: 0 auto;
+ margin-bottom: 15px;
+}
+
+h1 {
+ font-size: 3rem;
+ margin: 0;
+ margin-bottom: 12px;
+}
+
+p {
+ color: rgb(99, 99, 99);
+ font-size: 1.1rem;
+ margin: 0;
+ margin-bottom: 15px;
+}
+
+a.button {
+ padding: 15px 25px;
+ border-radius: 5px;
+ text-decoration: none;
+}
+
+a.button.danger {
+ background-color: rgb(235, 110, 111);
+ color: #fff;
+}
+
+a.button.normal {
+ background-color: rgb(252, 191, 44);
+ color: #fff;
+}
diff --git a/lib/auth/pages/success.ejs b/lib/auth/pages/success.ejs
new file mode 100644
index 00000000..7b51c2b5
--- /dev/null
+++ b/lib/auth/pages/success.ejs
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Resin CLI - Success
+
+
+
+
+
+
+
+
diff --git a/lib/auth/server.coffee b/lib/auth/server.coffee
new file mode 100644
index 00000000..ec4fefd1
--- /dev/null
+++ b/lib/auth/server.coffee
@@ -0,0 +1,101 @@
+###
+Copyright 2016 Resin.io
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+###
+
+express = require('express')
+path = require('path')
+bodyParser = require('body-parser')
+Promise = require('bluebird')
+resin = require('resin-sdk-preconfigured')
+utils = require('./utils')
+
+createServer = ({ port, isDev } = {}) ->
+ app = express()
+ app.use bodyParser.urlencoded
+ extended: true
+
+ app.set('view engine', 'ejs')
+ app.set('views', path.join(__dirname, 'pages'))
+
+ if isDev
+ app.use(express.static(path.join(__dirname, 'pages', 'static')))
+
+ server = app.listen(port)
+
+ return { app, server }
+
+###*
+# @summary Await for token
+# @function
+# @protected
+#
+# @param {Object} options - options
+# @param {String} options.path - callback path
+# @param {Number} options.port - http port
+#
+# @example
+# server.awaitForToken
+# path: '/auth'
+# port: 9001
+# .then (token) ->
+# console.log(token)
+###
+exports.awaitForToken = (options) ->
+ { app, server } = createServer(port: options.port)
+
+ return new Promise (resolve, reject) ->
+ closeServer = (errorMessage, successPayload) ->
+ server.close ->
+ if errorMessage
+ reject(new Error(errorMessage))
+ return
+
+ resolve(successPayload)
+
+ renderAndDone = ({ request, response, viewName, errorMessage, statusCode, token }) ->
+ return getContext(viewName)
+ .then (context) ->
+ response.status(statusCode || 200).render(viewName, context)
+ request.connection.destroy()
+ closeServer(errorMessage, token)
+
+ app.post options.path, (request, response) ->
+ token = request.body.token?.trim()
+
+ Promise.try ->
+ if not token
+ throw new Error('No token')
+ return utils.isTokenValid(token)
+ .tap (isValid) ->
+ if not isValid
+ throw new Error('Invalid token')
+ .then ->
+ renderAndDone({ request, response, viewName: 'success', token })
+ .catch (error) ->
+ renderAndDone({
+ request, response, viewName: 'error',
+ statusCode: 401, errorMessage: error.message
+ })
+
+ app.use (request, response) ->
+ response.status(404).send('Not found')
+ closeServer('Unknown path or verb')
+
+exports.getContext = getContext = (viewName) ->
+ if viewName is 'success'
+ return Promise.props
+ dashboardUrl: resin.settings.get('dashboardUrl')
+
+ return Promise.resolve({})
diff --git a/lib/auth/utils.coffee b/lib/auth/utils.coffee
new file mode 100644
index 00000000..6dbb69c6
--- /dev/null
+++ b/lib/auth/utils.coffee
@@ -0,0 +1,75 @@
+###
+Copyright 2016 Resin.io
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+###
+
+resin = require('resin-sdk-preconfigured')
+_ = require('lodash')
+url = require('url')
+Promise = require('bluebird')
+
+###*
+# @summary Get dashboard CLI login URL
+# @function
+# @protected
+#
+# @param {String} callbackUrl - callback url
+# @fulfil {String} - dashboard login url
+# @returns {Promise}
+#
+# @example
+# utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
+# console.log(url)
+###
+exports.getDashboardLoginURL = (callbackUrl) ->
+
+ # Encode percentages signs from the escaped url
+ # characters to avoid angular getting confused.
+ callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25')
+
+ resin.settings.get('dashboardUrl').then (dashboardUrl) ->
+ return url.resolve(dashboardUrl, "/login/cli/#{callbackUrl}")
+
+###*
+# @summary Check if a token is valid
+# @function
+# @protected
+#
+# @description
+# This function checks that the token is not only well-structured
+# but that it also authenticates with the server successfully.
+#
+# @param {String} sessionToken - token
+# @fulfil {Boolean} - whether is valid or not
+# @returns {Promise}
+#
+# utils.isTokenValid('...').then (isValid) ->
+# if isValid
+# console.log('Token is valid!')
+###
+exports.isTokenValid = (sessionToken) ->
+ if not sessionToken? or _.isEmpty(sessionToken.trim())
+ return Promise.resolve(false)
+
+ return resin.token.get().then (currentToken) ->
+ resin.auth.loginWithToken(sessionToken)
+ .return(sessionToken)
+ .then(resin.auth.isLoggedIn)
+ .tap (isLoggedIn) ->
+ return if isLoggedIn
+
+ if currentToken?
+ return resin.auth.loginWithToken(currentToken)
+ else
+ return resin.auth.logout()
diff --git a/package.json b/package.json
index fd326f68..ff186fee 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "resin-cli",
- "version": "6.10.1",
+ "version": "6.10.2",
"description": "The official resin.io CLI tool",
"main": "./build/actions/index.js",
"homepage": "https://github.com/resin-io/resin-cli",
@@ -19,8 +19,10 @@
"resin": "./bin/resin"
},
"scripts": {
- "build": "gulp coffee && tsc && npm run doc",
- "ci": "npm run build && catch-uncommitted",
+ "build": "gulp build && tsc && npm run doc",
+ "pretest": "npm run build",
+ "test": "gulp test",
+ "ci": "npm run test && catch-uncommitted",
"doc": "mkdir -p doc/ && coffee extras/capitanodoc/index.coffee > doc/cli.markdown",
"watch": "gulp watch",
"lint": "gulp lint",
@@ -42,7 +44,10 @@
"gulp": "^3.9.0",
"gulp-coffee": "^2.2.0",
"gulp-coffeelint": "^0.6.0",
+ "gulp-inline-source": "^2.1.0",
+ "gulp-mocha": "^2.0.0",
"gulp-shell": "^0.5.2",
+ "mochainon": "^2.0.0",
"require-npm4-to-publish": "^1.0.0",
"typescript": "^2.6.1"
},
@@ -52,6 +57,7 @@
"any-promise": "^1.3.0",
"bash": "0.0.1",
"bluebird": "^3.3.3",
+ "body-parser": "^1.14.1",
"capitano": "^1.7.0",
"chalk": "^1.1.3",
"coffee-script": "^1.12.6",
@@ -62,7 +68,9 @@
"dockerode": "^2.5.0",
"dockerode-options": "^0.2.1",
"drivelist": "^5.0.22",
+ "ejs": "^2.5.7",
"etcher-image-write": "^9.0.3",
+ "express": "^4.13.3",
"global-tunnel-ng": "github:zvin/global-tunnel#dont-proxy-connections-to-file-sockets",
"hasbin": "^1.2.3",
"inquirer": "^3.1.1",
@@ -75,6 +83,7 @@
"mz": "^2.6.0",
"node-cleanup": "^2.1.2",
"nplugm": "^3.0.0",
+ "open": "0.0.5",
"president": "^2.0.1",
"prettyjson": "^1.1.3",
"progress-stream": "^2.0.0",
@@ -82,7 +91,6 @@
"reconfix": "^0.0.3",
"request": "^2.81.0",
"resin-bundle-resolve": "^0.0.2",
- "resin-cli-auth": "^1.2.0",
"resin-cli-errors": "^1.2.0",
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.4.0",
@@ -105,7 +113,6 @@
"stream-to-promise": "^2.2.0",
"tmp": "0.0.31",
"umount": "^1.1.6",
- "underscore.string": "^3.1.1",
"unzip2": "^0.2.5",
"update-notifier": "^2.2.0"
},
diff --git a/tests/auth/server.spec.coffee b/tests/auth/server.spec.coffee
new file mode 100644
index 00000000..5f6fdc0a
--- /dev/null
+++ b/tests/auth/server.spec.coffee
@@ -0,0 +1,124 @@
+m = require('mochainon')
+request = require('request')
+Promise = require('bluebird')
+path = require('path')
+fs = require('fs')
+ejs = require('ejs')
+server = require('../../build/auth/server')
+utils = require('../../build/auth/utils')
+tokens = require('./tokens.json')
+
+options =
+ port: 3000
+ path: '/auth'
+
+getPage = (name) ->
+ pagePath = path.join(__dirname, '..', '..', 'build', 'auth', 'pages', "#{name}.ejs")
+ tpl = fs.readFileSync(pagePath, encoding: 'utf8')
+ compiledTpl = ejs.compile(tpl)
+ return server.getContext(name)
+ .then (context) ->
+ compiledTpl(context)
+
+describe 'Server:', ->
+
+ it 'should get 404 if posting to an unknown path', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.be.rejectedWith('Unknown path or verb')
+
+ request.post "http://localhost:#{options.port}/foobarbaz",
+ form:
+ token: tokens.johndoe.token
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(404)
+ m.chai.expect(body).to.equal('Not found')
+ done()
+
+ it 'should get 404 if not using the correct verb', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.be.rejectedWith('Unknown path or verb')
+
+ request.get "http://localhost:#{options.port}#{options.path}",
+ form:
+ token: tokens.johndoe.token
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(404)
+ m.chai.expect(body).to.equal('Not found')
+ done()
+
+ describe 'given the token authenticates with the server', ->
+
+ beforeEach ->
+ @utilsIsTokenValidStub = m.sinon.stub(utils, 'isTokenValid')
+ @utilsIsTokenValidStub.returns(Promise.resolve(true))
+
+ afterEach ->
+ @utilsIsTokenValidStub.restore()
+
+ it 'should eventually be the token', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.eventually.equal(tokens.johndoe.token)
+
+ request.post "http://localhost:#{options.port}#{options.path}",
+ form:
+ token: tokens.johndoe.token
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(200)
+ getPage('success').then (expectedBody) ->
+ m.chai.expect(body).to.equal(expectedBody)
+ done()
+
+ describe 'given the token does not authenticate with the server', ->
+
+ beforeEach ->
+ @utilsIsTokenValidStub = m.sinon.stub(utils, 'isTokenValid')
+ @utilsIsTokenValidStub.returns(Promise.resolve(false))
+
+ afterEach ->
+ @utilsIsTokenValidStub.restore()
+
+ it 'should be rejected', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.be.rejectedWith('Invalid token')
+
+ request.post "http://localhost:#{options.port}#{options.path}",
+ form:
+ token: tokens.johndoe.token
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(401)
+ getPage('error').then (expectedBody) ->
+ m.chai.expect(body).to.equal(expectedBody)
+ done()
+
+ it 'should be rejected if no token', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.be.rejectedWith('No token')
+
+ request.post "http://localhost:#{options.port}#{options.path}",
+ form:
+ token: ''
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(401)
+ getPage('error').then (expectedBody) ->
+ m.chai.expect(body).to.equal(expectedBody)
+ done()
+
+ it 'should be rejected if token is malformed', (done) ->
+ promise = server.awaitForToken(options)
+ m.chai.expect(promise).to.be.rejectedWith('Invalid token')
+
+ request.post "http://localhost:#{options.port}#{options.path}",
+ form:
+ token: 'asdf'
+ , (error, response, body) ->
+ m.chai.expect(error).to.not.exist
+ m.chai.expect(response.statusCode).to.equal(401)
+ getPage('error').then (expectedBody) ->
+ m.chai.expect(body).to.equal(expectedBody)
+ done()
+
diff --git a/tests/auth/tokens.json b/tests/auth/tokens.json
new file mode 100644
index 00000000..6b9eb4ad
--- /dev/null
+++ b/tests/auth/tokens.json
@@ -0,0 +1,18 @@
+{
+ "johndoe": {
+ "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UxIiwiZW1haWwiOiJqb2huZG9lQGpvaG5kb2UuY29tIiwiZ2l0bGFiX2lkIjoxMzI1LCJzb2NpYWxfc2VydmljZV9hY2NvdW50IjpudWxsLCJoYXNQYXNzd29yZFNldCI6dHJ1ZSwibmVlZHNQYXNzd29yZFJlc2V0IjpmYWxzZSwicHVibGljX2tleSI6ZmFsc2UsImZlYXR1cmVzIjpbXSwiaWQiOjEzNDQsImludGVyY29tVXNlckhhc2giOiJlMDM3NzhkZDI5ZTE1NzQ0NWYyNzJhY2M5MjExNzBjZjI4MTBiNjJmNTAyNjQ1MjY1Y2MzNDlkNmRlZGEzNTI0IiwicGVybWlzc2lvbnMiOltdLCJpYXQiOjE0MjY3ODMzMTJ9.v5bmh9HwyUZu8zhh1rA79mTL-1jzDOO8eUr_lVaBwhg",
+ "data": {
+ "email": "johndoe@johndoe.com",
+ "username": "johndoe1",
+ "id": 1344
+ }
+ },
+ "janedoe": {
+ "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTUyLCJ1c2VybmFtZSI6ImphbmVkb2UiLCJlbWFpbCI6ImphbmVkb2VAYXNkZi5jb20iLCJzb2NpYWxfc2VydmljZV9hY2NvdW50IjpudWxsLCJoYXNfZGlzYWJsZWRfbmV3c2xldHRlciI6dHJ1ZSwiaGFzUGFzc3dvcmRTZXQiOnRydWUsIm5lZWRzUGFzc3dvcmRSZXNldCI6ZmFsc2UsInB1YmxpY19rZXkiOmZhbHNlLCJmZWF0dXJlcyI6W10sImludGVyY29tVXNlckhhc2giOiIwYjRmOWViNDRiMzcxZjBlMzI4ZWY1ZmUwM2FkN2ViMmY1ZjcyZGQ0MThlZjIzMTQ5ZDUyODcwOTY1NThjZTAzIiwicGVybWlzc2lvbnMiOltdLCJpYXQiOjE0MzUzMjAyNjN9.jVzUFu58vzdJFctR8ulyjGL0Em1kjIZSbSxX2SeU03Y",
+ "data": {
+ "email": "janedoe@asdf.com",
+ "username": "janedoe",
+ "id": 152
+ }
+ }
+}
diff --git a/tests/auth/utils.spec.coffee b/tests/auth/utils.spec.coffee
new file mode 100644
index 00000000..152b32c2
--- /dev/null
+++ b/tests/auth/utils.spec.coffee
@@ -0,0 +1,111 @@
+m = require('mochainon')
+url = require('url')
+Promise = require('bluebird')
+resin = require('resin-sdk-preconfigured')
+utils = require('../../build/auth/utils')
+tokens = require('./tokens.json')
+
+describe 'Utils:', ->
+
+ describe '.getDashboardLoginURL()', ->
+
+ it 'should eventually be a valid url', (done) ->
+ utils.getDashboardLoginURL('https://127.0.0.1:3000/callback').then (loginUrl) ->
+ m.chai.expect ->
+ url.parse(loginUrl)
+ .to.not.throw(Error)
+ .nodeify(done)
+
+ it 'should eventually contain an https protocol', (done) ->
+ Promise.props
+ dashboardUrl: resin.settings.get('dashboardUrl')
+ loginUrl: utils.getDashboardLoginURL('https://127.0.0.1:3000/callback')
+ .then ({ dashboardUrl, loginUrl }) ->
+ protocol = url.parse(loginUrl).protocol
+ m.chai.expect(protocol).to.equal(url.parse(dashboardUrl).protocol)
+ .nodeify(done)
+
+ it 'should correctly escape a callback url without a path', (done) ->
+ Promise.props
+ dashboardUrl: resin.settings.get('dashboardUrl')
+ loginUrl: utils.getDashboardLoginURL('http://127.0.0.1:3000')
+ .then ({ dashboardUrl, loginUrl }) ->
+ expectedUrl = "#{dashboardUrl}/login/cli/http%253A%252F%252F127.0.0.1%253A3000"
+ m.chai.expect(loginUrl).to.equal(expectedUrl)
+ .nodeify(done)
+
+ it 'should correctly escape a callback url with a path', (done) ->
+ Promise.props
+ dashboardUrl: resin.settings.get('dashboardUrl')
+ loginUrl: utils.getDashboardLoginURL('http://127.0.0.1:3000/callback')
+ .then ({ dashboardUrl, loginUrl }) ->
+ expectedUrl = "#{dashboardUrl}/login/cli/http%253A%252F%252F127.0.0.1%253A3000%252Fcallback"
+ m.chai.expect(loginUrl).to.equal(expectedUrl)
+ .nodeify(done)
+
+ describe '.isTokenValid()', ->
+
+ it 'should eventually be false if token is undefined', ->
+ promise = utils.isTokenValid(undefined)
+ m.chai.expect(promise).to.eventually.be.false
+
+ it 'should eventually be false if token is null', ->
+ promise = utils.isTokenValid(null)
+ m.chai.expect(promise).to.eventually.be.false
+
+ it 'should eventually be false if token is an empty string', ->
+ promise = utils.isTokenValid('')
+ m.chai.expect(promise).to.eventually.be.false
+
+ it 'should eventually be false if token is a string containing only spaces', ->
+ promise = utils.isTokenValid(' ')
+ m.chai.expect(promise).to.eventually.be.false
+
+ describe 'given the token does not authenticate with the server', ->
+
+ beforeEach ->
+ @resinAuthIsLoggedInStub = m.sinon.stub(resin.auth, 'isLoggedIn')
+ @resinAuthIsLoggedInStub.returns(Promise.resolve(false))
+
+ afterEach ->
+ @resinAuthIsLoggedInStub.restore()
+
+ it 'should eventually be false', ->
+ promise = utils.isTokenValid(tokens.johndoe.token)
+ m.chai.expect(promise).to.eventually.be.false
+
+ describe 'given there was a token already', ->
+
+ beforeEach (done) ->
+ resin.auth.loginWithToken(tokens.janedoe.token).nodeify(done)
+
+ it 'should preserve the old token', (done) ->
+ resin.auth.getToken().then (originalToken) ->
+ m.chai.expect(originalToken).to.equal(tokens.janedoe.token)
+ return utils.isTokenValid(tokens.johndoe.token)
+ .then(resin.auth.getToken).then (currentToken) ->
+ m.chai.expect(currentToken).to.equal(tokens.janedoe.token)
+ .nodeify(done)
+
+ describe 'given there was no token', ->
+
+ beforeEach (done) ->
+ resin.auth.logout().nodeify(done)
+
+ it 'should stay without a token', (done) ->
+ utils.isTokenValid(tokens.johndoe.token).then ->
+ m.chai.expect(resin.token.get()).to.eventually.not.exist
+ .nodeify(done)
+
+ describe 'given the token does authenticate with the server', ->
+
+ beforeEach ->
+ @resinAuthIsLoggedInStub = m.sinon.stub(resin.auth, 'isLoggedIn')
+ @resinAuthIsLoggedInStub.returns(Promise.resolve(true))
+
+ afterEach ->
+ @resinAuthIsLoggedInStub.restore()
+
+ it 'should eventually be true', ->
+ promise = utils.isTokenValid(tokens.johndoe.token)
+ m.chai.expect(promise).to.eventually.be.true