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 + + + + + +
+ +

Something went wrong

+

You couldn't login to the Resin CLI for some reason

+
+
+ Get help in our forums +
+ + 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 + + + + + +
+ +

Success!

+

You successfully logged in the Resin CLI

+
+
+ Go to the dashboard +
+ + 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