Merge lp:~gary/juju-gui/authtoken into lp:juju-gui/experimental

Proposed by Gary Poster
Status: Merged
Merged at revision: 1213
Proposed branch: lp:~gary/juju-gui/authtoken
Merge into: lp:juju-gui/experimental
Diff against target: 1162 lines (+790/-126)
11 files modified
app/app.js (+94/-19)
app/store/env/fakebackend.js (+21/-0)
app/store/env/go.js (+51/-3)
app/store/env/sandbox.js (+32/-3)
app/views/login.js (+8/-2)
test/test_app.js (+186/-2)
test/test_env_go.js (+208/-97)
test/test_fakebackend.js (+31/-0)
test/test_login.js (+1/-0)
test/test_sandbox_go.js (+87/-0)
test/utils.js (+71/-0)
To merge this branch: bzr merge lp:~gary/juju-gui/authtoken
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+196782@code.launchpad.net

Description of the change

Add authtoken support to the GUI

The authtoken support in this branch includes support for both a real environment and the sandbox. In writing this and testing it, I encountered some code that was not working, and some code that was not tested, and some code that was very difficult to test. This branch is utterly gigantic, for which I apologize. It includes the changes I needed to get everything else working. I should factor it out, now that I have figured out what needs to be done, but I'm a bit fatigued, so I'm asking for reviewer indulgence.

There are two ways you should QA. First, in the sandbox, delete the user and password from the config-debug file and then load the GUI in your browser using a URL like this: http://localhost:8888?authtoken=demoToken . This should log you in, remove the authtoken from the URL, and notify you that you used a token to authenticate. Find some other URLs, like in the charm browser, and copy them. Log out with the button on the top right. Paste the previous URL in to the browser, and then insert ?authtoken=demoToken in the URL. *Note that a querystring should come before a hash, so ?authtoken=demoToken#bws-whatever is correct, not the other way around.* Maybe do that log in, log out cycle a couple of times to try a few different URLs. Now log out and try a different token, like ?authtoken=badToken. It should send you to the login page with an appropriate error message.

Now it's time to QA a live environment. Here's how I suggest you do it.

1. In this branch, run BRANCH_IS_GOOD=1 make distfile . When it is finished, it will tell you what file it made.

2. Get a copy of the lp:~juju-gui/charms/precise/juju-gui/trunk/ branch if you don't have one already. If you do have one, make sure it is up to date.

3. mv the file you made in step 1 to the charm's releases directory. rm the old release in that directory.

4. juju bootstrap.

5. In the charm, run make deploy. Wait until it says it is done deploying the code.

6. Start up Python in your local machine. Edit the following code to include the address from step 5 and the appropriate password from your ~/.juju/environments.yaml file.

import itertools
import json
import pprint
import websocket
address = 'PUBLICADDRESS' # e.g. ec2-107-21-197-193.compute-1.amazonaws.com
password = 'YOURPASSWORD'
url = 'wss://{}:443/ws'.format(address)
ws = websocket.create_connection(url)
counter = itertools.count()
def process(request):
    request = request.copy()
    request['RequestId'] = counter.next()
    ws.send(json.dumps(request))
    pprint.pprint(json.loads(ws.recv()))

process(dict(Type='Admin', Request='Login', Params={'AuthTag': 'user-admin', 'Password': password}))
process(dict(Type='GUIToken', Request='Create', Params={}))

The last response should be something like this:

{u'RequestId': 2,
 u'Response': {u'Created': u'2013-11-25T20:11:41.624417Z',
               u'Expires': u'2013-11-25T20:13:41.624417Z',
               u'Token': u'e8ea8ac912fc4ef6a355e82bb65caf6d'}}

7. Now in your browser construct a url that has the GUI address from step 5 and the authtoken from step 6. It should look something like this:

https://PUBLICADDRESS/?authtoken=AUTHTOKEN

Go to this address. It should log you in as it did in the sandbox.

8. Try logging in and out with different methods to see if everything works as you expect.

Thank you very much!!!

https://codereview.appspot.com/33290043/

To post a comment you must log in.
Revision history for this message
Gary Poster (gary) wrote :
Download full text (3.9 KiB)

Reviewers: mp+196782_code.launchpad.net,

Message:
Please take a look.

Description:
Add authtoken support to the GUI

The authtoken support in this branch includes support for both a real
environment and the sandbox. In writing this and testing it, I
encountered some code that was not working, and some code that was not
tested, and some code that was very difficult to test. This branch is
utterly gigantic, for which I apologize. It includes the changes I
needed to get everything else working. I should factor it out, now that
I have figured out what needs to be done, but I'm a bit fatigued, so I'm
asking for reviewer indulgence.

There are two ways you should QA. First, in the sandbox, delete the
user and password from the config-debug file and then load the GUI in
your browser using a URL like this:
http://localhost:8888?authtoken=demoToken . This should log you in,
remove the authtoken from the URL, and notify you that you used a token
to authenticate. Find some other URLs, like in the charm browser, and
copy them. Log out with the button on the top right. Paste the
previous URL in to the browser, and then insert ?authtoken=demoToken in
the URL. *Note that a querystring should come before a hash, so
?authtoken=demoToken#bws-whatever is correct, not the other way around.*
  Maybe do that log in, log out cycle a couple of times to try a few
different URLs. Now log out and try a different token, like
?authtoken=badToken. It should send you to the login page with an
appropriate error message.

Now it's time to QA a live environment. Here's how I suggest you do it.

1. In this branch, run BRANCH_IS_GOOD=1 make distfile . When it is
finished, it will tell you what file it made.

2. Get a copy of the lp:~juju-gui/charms/precise/juju-gui/trunk/ branch
if you don't have one already. If you do have one, make sure it is up
to date.

3. mv the file you made in step 1 to the charm's releases directory. rm
the old release in that directory.

4. juju bootstrap.

5. In the charm, run make deploy. Wait until it says it is done
deploying the code.

6. Start up Python in your local machine. Edit the following code to
include the address from step 5 and the appropriate password from your
~/.juju/environments.yaml file.

import itertools
import json
import pprint
import websocket
address = 'PUBLICADDRESS' # e.g.
ec2-107-21-197-193.compute-1.amazonaws.com
password = 'YOURPASSWORD'
url = 'wss://{}:443/ws'.format(address)
ws = websocket.create_connection(url)
counter = itertools.count()
def process(request):
     request = request.copy()
     request['RequestId'] = counter.next()
     ws.send(json.dumps(request))
     pprint.pprint(json.loads(ws.recv()))

process(dict(Type='Admin', Request='Login', Params={'AuthTag':
'user-admin', 'Password': password}))
process(dict(Type='GUIToken', Request='Create', Params={}))

The last response should be something like this:

{u'RequestId': 2,
  u'Response': {u'Created': u'2013-11-25T20:11:41.624417Z',
                u'Expires': u'2013-11-25T20:13:41.624417Z',
                u'Token': u'e8ea8ac912fc4ef6a355e82bb65caf6d'}}

7. Now in your browser construct a url that has the GUI address from
step 5 and the ...

Read more...

Revision history for this message
Madison Scott-Clary (makyo) wrote :

Minors, will QA next.

https://codereview.appspot.com/33290043/diff/1/app/app.js
File app/app.js (right):

https://codereview.appspot.com/33290043/diff/1/app/app.js#newcode521
app/app.js:521: // proxy, withing an authenticated websocket session,
use a
*within

https://codereview.appspot.com/33290043/diff/1/app/store/env/go.js
File app/store/env/go.js (right):

https://codereview.appspot.com/33290043/diff/1/app/store/env/go.js#newcode342
app/store/env/go.js:342: // Indicate if the authentication were from a
token.
*was

https://codereview.appspot.com/33290043/

Revision history for this message
Jeff Pihach (hatch) wrote :

LGTM very thorough thanks!

https://codereview.appspot.com/33290043/

lp:~gary/juju-gui/authtoken updated
1214. By Gary Poster

respond to review

Revision history for this message
Madison Scott-Clary (makyo) wrote :
Revision history for this message
Gary Poster (gary) wrote :
Download full text (3.5 KiB)

*** Submitted:

Add authtoken support to the GUI

The authtoken support in this branch includes support for both a real
environment and the sandbox. In writing this and testing it, I
encountered some code that was not working, and some code that was not
tested, and some code that was very difficult to test. This branch is
utterly gigantic, for which I apologize. It includes the changes I
needed to get everything else working. I should factor it out, now that
I have figured out what needs to be done, but I'm a bit fatigued, so I'm
asking for reviewer indulgence.

There are two ways you should QA. First, in the sandbox, delete the
user and password from the config-debug file and then load the GUI in
your browser using a URL like this:
http://localhost:8888?authtoken=demoToken . This should log you in,
remove the authtoken from the URL, and notify you that you used a token
to authenticate. Find some other URLs, like in the charm browser, and
copy them. Log out with the button on the top right. Paste the
previous URL in to the browser, and then insert ?authtoken=demoToken in
the URL. *Note that a querystring should come before a hash, so
?authtoken=demoToken#bws-whatever is correct, not the other way around.*
  Maybe do that log in, log out cycle a couple of times to try a few
different URLs. Now log out and try a different token, like
?authtoken=badToken. It should send you to the login page with an
appropriate error message.

Now it's time to QA a live environment. Here's how I suggest you do it.

1. In this branch, run BRANCH_IS_GOOD=1 make distfile . When it is
finished, it will tell you what file it made.

2. Get a copy of the lp:~juju-gui/charms/precise/juju-gui/trunk/ branch
if you don't have one already. If you do have one, make sure it is up
to date.

3. mv the file you made in step 1 to the charm's releases directory. rm
the old release in that directory.

4. juju bootstrap.

5. In the charm, run make deploy. Wait until it says it is done
deploying the code.

6. Start up Python in your local machine. Edit the following code to
include the address from step 5 and the appropriate password from your
~/.juju/environments.yaml file.

import itertools
import json
import pprint
import websocket
address = 'PUBLICADDRESS' # e.g.
ec2-107-21-197-193.compute-1.amazonaws.com
password = 'YOURPASSWORD'
url = 'wss://{}:443/ws'.format(address)
ws = websocket.create_connection(url)
counter = itertools.count()
def process(request):
     request = request.copy()
     request['RequestId'] = counter.next()
     ws.send(json.dumps(request))
     pprint.pprint(json.loads(ws.recv()))

process(dict(Type='Admin', Request='Login', Params={'AuthTag':
'user-admin', 'Password': password}))
process(dict(Type='GUIToken', Request='Create', Params={}))

The last response should be something like this:

{u'RequestId': 2,
  u'Response': {u'Created': u'2013-11-25T20:11:41.624417Z',
                u'Expires': u'2013-11-25T20:13:41.624417Z',
                u'Token': u'e8ea8ac912fc4ef6a355e82bb65caf6d'}}

7. Now in your browser construct a url that has the GUI address from
step 5 and the authtoken from step 6. It should look something like
this:

https://...

Read more...

Revision history for this message
Gary Poster (gary) wrote :

Thanks again to both of you, and apologies for the size.

https://codereview.appspot.com/33290043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'app/app.js'
2--- app/app.js 2013-11-18 13:27:49 +0000
3+++ app/app.js 2013-11-26 22:03:26 +0000
4@@ -500,6 +500,9 @@
5 });
6 this.endpointsController.bind();
7
8+ // Stash the location object so that tests can override it.
9+ this.location = window.location;
10+
11 // When the connection resets, reset the db, re-login (a delta will
12 // arrive with successful authentication), and redispatch.
13 this.env.after('connectedChange', function(ev) {
14@@ -511,7 +514,33 @@
15 if (credentials && credentials.areAvailable) {
16 this.env.login();
17 } else {
18- this.checkUserCredentials();
19+ // The user can also try to log in with an authentication token.
20+ // This will look like ?authtoken=AUTHTOKEN. For instance,
21+ // in the sandbox, try logging in with ?authtoken=demoToken.
22+ // To get a real token from the Juju GUI charm's environment
23+ // proxy, within an authenticated websocket session, use a
24+ // request like this:
25+ // {
26+ // 'RequestId': 42,
27+ // 'Type': 'GUIToken',
28+ // 'Request': 'Create',
29+ // 'Params': {},
30+ // }
31+ // You can then use the token once until it expires, within two
32+ // minutes of this writing.
33+ var querystring = this.location.search.substring(1);
34+ var qs = Y.QueryString.parse(querystring);
35+ var authtoken = qs.authtoken;
36+ if (Y.Lang.isValue(authtoken)) {
37+ // De-dupe if necessary.
38+ if (Y.Lang.isArray(authtoken)) {
39+ authtoken = authtoken[0];
40+ }
41+ // Try a token login.
42+ this.env.tokenLogin(authtoken);
43+ } else {
44+ this.checkUserCredentials();
45+ }
46 }
47 }
48 }, this);
49@@ -895,6 +924,31 @@
50 },
51
52 /**
53+ Get the path to which we should redirect after logging in. Clear it out
54+ afterwards so it is clear that we've consumed it.
55+
56+ This is logic from the onLogin method factored out to make it easier to
57+ test.
58+
59+ @method popLoginRedirectPath
60+ @private
61+ @return {String} the path to which we should redirect.
62+ */
63+ popLoginRedirectPath: function() {
64+ var result = this.redirectPath;
65+ delete this.redirectPath;
66+ var currentPath = this.get('currentUrl');
67+ var loginPath = /^\/login(\/|$)/;
68+ if (currentPath !== '/' && !loginPath.test(currentPath)) {
69+ // We used existing credentials or a token to go directly to a url.
70+ result = currentPath;
71+ } else if (!result || loginPath.test(result)) {
72+ result = '/';
73+ }
74+ return result;
75+ },
76+
77+ /**
78 * Hide the login mask and redispatch the router.
79 *
80 * When the environment gets a response from a login attempt,
81@@ -906,25 +960,31 @@
82 */
83 onLogin: function(e) {
84 if (e.data.result) {
85- // We need to save the url to continue on to without redirecting
86- // to root if there are extra path details.
87+ // The login was a success.
88 this.hideMask();
89- var originalPath = this.get('currentUrl');
90- if (originalPath !== '/' && !originalPath.match(/\/login\//)) {
91- this.redirectPath = originalPath;
92+ var redirectPath = this.popLoginRedirectPath();
93+ // Handle token authentication.
94+ if (e.data.fromToken) {
95+ // Alert the user. In the future, we might want to call out the
96+ // password so the user can note it. That will probably want a
97+ // modal or similar.
98+ this.env.onceAfter('environmentNameChange', function() {
99+ this.db.notifications.add(
100+ new models.Notification({
101+ title: 'Logged in with Token',
102+ message: ('You have successfully logged in with a ' +
103+ 'single-use authentication token.'),
104+ level: 'important'
105+ })
106+ );
107+ }, this);
108 }
109- if (originalPath.match(/login/) && this.redirectPath === '/') {
110+ if (redirectPath === '/') {
111 setTimeout(
112 Y.bind(this.showRootView, this), 0);
113 return;
114 } else {
115- var nsRouter = this.nsRouter;
116-
117- this.navigate(
118- nsRouter.url(nsRouter.parse(this.redirectPath)),
119- {overrideAllNamespaces: true});
120- this.redirectPath = null;
121- return;
122+ this.navigate(redirectPath, {overrideAllNamespaces: true});
123 }
124 } else {
125 this.showLogin();
126@@ -1239,11 +1299,25 @@
127 * @attribute currentUrl.getter
128 */
129 getter: function() {
130- return [
131- window.location.pathname,
132- window.location.search,
133- window.location.hash
134- ].join('');
135+ // The result is a normalized version of the currentURL.
136+ // Specifically, it omits any authtokens and uses our standard path
137+ // normalizing tool (currently the nsRouter).
138+ var nsRouter = this.nsRouter;
139+ // `this.location` is a test-friendly access of window.location.
140+ var routes = nsRouter.parse(this.location.toString());
141+ if (routes.search) {
142+ var qs = Y.QueryString.parse(routes.search);
143+ var authtoken = qs.authtoken;
144+ if (Y.Lang.isValue(authtoken)) {
145+ // Remove the token from the URL. It is a one-shot, designed to
146+ // be consumed. We don't want it to be in the URL after it has
147+ // been used.
148+ delete qs.authtoken;
149+ routes.search = Y.QueryString.stringify(qs);
150+ }
151+ }
152+ // Use the nsRouter to normalize.
153+ return nsRouter.url(routes);
154 }
155 },
156 /**
157@@ -1358,6 +1432,7 @@
158 'model',
159 'app-cookies-extension',
160 'cookie',
161+ 'querystring',
162 'app-subapp-extension',
163 'sub-app',
164 'subapp-browser',
165
166=== modified file 'app/store/env/fakebackend.js'
167--- app/store/env/fakebackend.js 2013-11-20 19:38:03 +0000
168+++ app/store/env/fakebackend.js 2013-11-26 22:03:26 +0000
169@@ -44,6 +44,7 @@
170 FakeBackend.NAME = 'fake-backend';
171 FakeBackend.ATTRS = {
172 authorizedUsers: {value: {'admin': 'password'}},
173+ token: {value: 'demoToken'},
174 authenticated: {value: false},
175 store: {required: true},
176 defaultSeries: {value: 'precise'},
177@@ -171,6 +172,26 @@
178 return authenticated;
179 },
180
181+
182+ /**
183+ Attempt to log a user in with a token.
184+
185+ @method tokenlogin
186+ @param {String} submittedToken The authentication token.
187+ @return {Array} [username, password] if successful, or else undefined.
188+ */
189+ tokenlogin: function(submittedToken) {
190+ var token = this.get('token'),
191+ authorizedUsers = this.get('authorizedUsers'),
192+ authenticated = token === submittedToken;
193+ this.set('authenticated', authenticated);
194+ if (authenticated) {
195+ var username = Object.keys(authorizedUsers)[0];
196+ var password = authorizedUsers[username];
197+ return [username, password];
198+ }
199+ },
200+
201 /**
202 Log out. If already logged out, no error is raised.
203 @method logout
204
205=== modified file 'app/store/env/go.js'
206--- app/store/env/go.js 2013-11-20 18:45:37 +0000
207+++ app/store/env/go.js 2013-11-26 22:03:26 +0000
208@@ -317,21 +317,47 @@
209 *
210 * @method handleLogin
211 * @param {Object} data The response returned by the server.
212+ * param {Bool} fromToken Whether the login request was via a token.
213 * @return {undefined} Nothing.
214 */
215- handleLogin: function(data) {
216+ handleLogin: function(data, fromToken) {
217+ fromToken = !!fromToken; // Normalize.
218 this.pendingLoginResponse = false;
219 this.userIsAuthenticated = !data.Error;
220 if (this.userIsAuthenticated) {
221+ // If this is a token login, set the credentials.
222+ var response = data.Response;
223+ if (response && response.AuthTag && response.Password) {
224+ this.setCredentials({
225+ user: response.AuthTag, password: response.Password});
226+ }
227 // If login succeeded retrieve the environment info.
228 this.environmentInfo();
229 this._watchAll();
230+ // Clean up for log out text.
231+ this.failedAuthentication = this.failedTokenAuthentication = false;
232 } else {
233 // If the credentials were rejected remove them.
234 this.setCredentials(null);
235- this.failedAuthentication = true;
236+ // Indicate if the authentication was from a token.
237+ this.failedAuthentication = !fromToken;
238+ this.failedTokenAuthentication = fromToken;
239 }
240- this.fire('login', {data: {result: this.userIsAuthenticated}});
241+ this.fire(
242+ 'login',
243+ {data: {result: this.userIsAuthenticated,
244+ fromToken: fromToken}});
245+ },
246+
247+ /**
248+ * React to the results of sending a token login message to the server.
249+ *
250+ * @method handleTokenLogin
251+ * @param {Object} data The response returned by the server.
252+ * @return {undefined} Nothing.
253+ */
254+ handleTokenLogin: function(data) {
255+ this.handleLogin(data, true);
256 },
257
258 /**
259@@ -368,6 +394,28 @@
260 },
261
262 /**
263+ * Attempt to log the user in with a token.
264+ *
265+ * @method tokenLogin
266+ * @return {undefined} Nothing.
267+ */
268+ tokenLogin: function(token) {
269+ // If the user is already authenticated there is nothing to do.
270+ if (this.userIsAuthenticated) {
271+ this.fire('login', {data: {result: true}});
272+ return;
273+ }
274+ if (this.pendingLoginResponse) {
275+ return;
276+ }
277+ this._send_rpc({
278+ Type: 'GUIToken',
279+ Request: 'Login',
280+ Params: {Token: token}
281+ }, this.handleTokenLogin);
282+ },
283+
284+ /**
285 * Store the environment info coming from the server.
286 *
287 * @method handleEnvironmentInfo
288
289=== modified file 'app/store/env/sandbox.js'
290--- app/store/env/sandbox.js 2013-11-20 19:42:15 +0000
291+++ app/store/env/sandbox.js 2013-11-26 22:03:26 +0000
292@@ -820,6 +820,32 @@
293 },
294
295 /**
296+ Handle GUIToken Login messages to the state object.
297+
298+ @method handleGUITokenLogin
299+ @param {Object} data The contents of the API arguments.
300+ @param {Object} client The active ClientConnection.
301+ @param {Object} state An instance of FakeBackend.
302+ @return {undefined} Side effects only.
303+ */
304+ handleGUITokenLogin: function(data, client, state) {
305+ var response = state.tokenlogin(data.Params.Token);
306+ if (response) {
307+ client.receive({
308+ RequestId: data.RequestId,
309+ Response: {AuthTag: response[0], Password: response[1]}
310+ });
311+ } else {
312+ client.receive({
313+ RequestId: data.RequestId,
314+ Error: 'unknown, fulfilled, or expired token',
315+ ErrorCode: 'unauthorized access',
316+ Response: {}
317+ });
318+ }
319+ },
320+
321+ /**
322 Handle EnvironmentView messages.
323
324 @method handleClientEnvironmentInfo
325@@ -830,9 +856,12 @@
326 */
327 handleClientEnvironmentInfo: function(data, client, state) {
328 client.receive({
329- ProviderType: state.get('providerType'),
330- DefaultSeries: state.get('defaultSeries'),
331- Name: 'Sandbox'
332+ RequestId: data.RequestId,
333+ Response: {
334+ ProviderType: state.get('providerType'),
335+ DefaultSeries: state.get('defaultSeries'),
336+ Name: 'Sandbox'
337+ }
338 });
339 },
340
341
342=== modified file 'app/views/login.js'
343--- app/views/login.js 2013-05-17 14:51:05 +0000
344+++ app/views/login.js 2013-11-26 22:03:26 +0000
345@@ -95,14 +95,20 @@
346 environment_name_node.get('text') : 'Environment');
347 var provider_type = (
348 provider_type_node ? provider_type_node.get('text') : '');
349+ var error_text = '';
350+ if (env.failedAuthentication) {
351+ error_text = 'Unknown user or password.';
352+ } else if (env.failedTokenAuthentication) {
353+ error_text = (
354+ 'The one-time token was unknown, expired, or already used.');
355+ }
356 // In order to have events work and the view cleanly be replaced by
357 // other views, we need to put the contents in the usual "container"
358 // node, even though it is not a child of the mask node.
359 this.get('container').setHTML(this.template({
360 environment_name: environment_name,
361 provider_type: provider_type,
362- error_text: (
363- env.failedAuthentication ? 'Unknown user or password.' : ''),
364+ error_text: error_text,
365 user: env.get('user') || env.defaultUser,
366 help_text: this.get('help_text')
367 }));
368
369=== modified file 'test/test_app.js'
370--- test/test_app.js 2013-11-05 18:10:05 +0000
371+++ test/test_app.js 2013-11-26 22:03:26 +0000
372@@ -346,6 +346,54 @@
373 });
374 });
375
376+ it('uses the authtoken when there are no credentials', function(done) {
377+ var app = makeApp(false);
378+ // Override the local window.location object.
379+ app.location = {search: '?authtoken=demoToken'};
380+ env.setCredentials(null);
381+ env.connect();
382+ app.after('ready', function() {
383+ assert.equal(conn.messages.length, 1);
384+ assert.deepEqual(conn.last_message(), {
385+ RequestId: 1,
386+ Type: 'GUIToken',
387+ Request: 'Login',
388+ Params: {Token: 'demoToken'}
389+ });
390+ done();
391+ });
392+ });
393+
394+ it('handles multiple authtokens', function(done) {
395+ var app = makeApp(false);
396+ // Override the local window.location object.
397+ app.location = {search: '?authtoken=demoToken&authtoken=discarded'};
398+ env.setCredentials(null);
399+ env.connect();
400+ app.after('ready', function() {
401+ assert.equal(conn.messages.length, 1);
402+ assert.deepEqual(conn.last_message(), {
403+ RequestId: 1,
404+ Type: 'GUIToken',
405+ Request: 'Login',
406+ Params: {Token: 'demoToken'}
407+ });
408+ done();
409+ });
410+ });
411+
412+ it('ignores the authtoken if credentials exist', function(done) {
413+ var app = makeApp(false);
414+ // Override the local window.location object.
415+ app.location = {search: '?authtoken=demoToken'};
416+ env.connect();
417+ app.after('ready', function() {
418+ assert.equal(1, conn.messages.length);
419+ assertIsLogin(conn.last_message());
420+ done();
421+ });
422+ });
423+
424 it('displays the login view if credentials are not valid', function(done) {
425 var app = makeApp(true); // Create a connected app.
426 app.after('ready', function() {
427@@ -360,12 +408,14 @@
428 });
429
430 it('login method handler is called after successful login', function(done) {
431- var oldOnLogin = Y.juju.App.onLogin;
432+ var oldOnLogin = Y.juju.App.prototype.onLogin;
433 Y.juju.App.prototype.onLogin = function(e) {
434+ // Clean up.
435+ Y.juju.App.prototype.onLogin = oldOnLogin;
436+ // Begin assertions.
437 assert.equal(conn.messages.length, 1);
438 assertIsLogin(conn.last_message());
439 assert.isTrue(e.data.result, true);
440- Y.juju.App.onLogin = oldOnLogin;
441 done();
442 };
443 var app = new Y.juju.App({ env: env, viewContainer: container });
444@@ -375,6 +425,50 @@
445 app.destroy(true);
446 });
447
448+ it('creates a notification if logged in with a token', function(done) {
449+ // We need to change the prototype before we instantiate.
450+ // See the "this.reset()" call in the callback below that cleans up.
451+ var stub = utils.makeStubMethod(Y.juju.App.prototype, 'onLogin');
452+ var app = makeApp(false);
453+ utils.makeStubMethod(app, 'hideMask');
454+ app.redirectPath = '/foo/bar/';
455+ app.location = {
456+ toString: function() {return '/login/';},
457+ search: '?authtoken=demoToken'};
458+ utils.makeStubMethod(app.env, 'onceAfter');
459+ utils.makeStubMethod(app, 'navigate');
460+ stub.addCallback(function() {
461+ // Clean up.
462+ this.reset();
463+ // Begin assertions.
464+ var e = this.lastArguments()[0];
465+ // These two really simply verify that our test prep did what we
466+ // expected.
467+ assert.isTrue(e.data.result);
468+ assert.isTrue(e.data.fromToken);
469+ this.passThroughToOriginalMethod(app);
470+ assert.isTrue(app.hideMask.calledOnce());
471+ assert.isTrue(app.env.onceAfter.calledOnce());
472+ var onceAfterArgs = app.env.onceAfter.lastArguments();
473+ assert.equal(onceAfterArgs[0], 'environmentNameChange');
474+ // Call the event handler so we can verify what it does.
475+ onceAfterArgs[1].call(onceAfterArgs[2]);
476+ assert.equal(
477+ app.db.notifications.item(0).get('title'),
478+ 'Logged in with Token');
479+ assert.isTrue(app.navigate.calledOnce());
480+ var navigateArgs = app.navigate.lastArguments();
481+ assert.equal(navigateArgs[0], '/foo/bar/');
482+ assert.deepEqual(navigateArgs[1], {overrideAllNamespaces: true});
483+ done();
484+ });
485+ env.setCredentials(null);
486+ env.connect();
487+ conn.msg({
488+ RequestId: conn.last_message().RequestId,
489+ Response: {AuthTag: 'tokenuser', Password: 'tokenpasswd'}});
490+ });
491+
492 it('tries to log in on first connection', function(done) {
493 // This is the case when credential are stashed.
494 var app = makeApp(true); // Create a disconnected app.
495@@ -406,6 +500,96 @@
496 assert.equal(null, env.getCredentials());
497 });
498
499+ it('normally uses window.location', function() {
500+ // A lot of the app's authentication dance uses window.location,
501+ // both for redirects after login and for authtokens. For tests,
502+ // the app copies window.location to app.location, so that we
503+ // can easily override it. This test verifies that the initialization
504+ // actually does stash window.location as we exprect.
505+ var app = makeApp(false);
506+ assert.strictEqual(window.location, app.location);
507+ });
508+
509+ describe('popLoginRedirectPath', function() {
510+ it('returns and clears redirectPath', function() {
511+ var app = makeApp(false);
512+ app.redirectPath = '/foo/bar/';
513+ app.location = {toString: function() {return '/login/';}};
514+ assert.equal(app.popLoginRedirectPath(), '/foo/bar/');
515+ assert.isUndefined(app.redirectPath);
516+ });
517+
518+ it('prefers the current path if not login', function() {
519+ var app = makeApp(false);
520+ app.redirectPath = '/';
521+ app.location = {toString: function() {return '/foo/bar/';}};
522+ assert.equal(app.popLoginRedirectPath(), '/foo/bar/');
523+ assert.isUndefined(app.redirectPath);
524+ });
525+
526+ it('uses root if the redirectPath is /login/', function() {
527+ var app = makeApp(false);
528+ app.redirectPath = '/login/';
529+ app.location = {toString: function() {return '/login/';}};
530+ assert.equal(app.popLoginRedirectPath(), '/');
531+ assert.isUndefined(app.redirectPath);
532+ });
533+
534+ it('uses root if the redirectPath is /login', function() {
535+ var app = makeApp(false);
536+ // Missing trailing slash is only difference from previous test.
537+ app.redirectPath = '/login';
538+ app.location = {toString: function() {return '/login';}};
539+ assert.equal(app.popLoginRedirectPath(), '/');
540+ assert.isUndefined(app.redirectPath);
541+ });
542+ });
543+
544+ describe('currentUrl', function() {
545+ it('returns the full current path', function() {
546+ var app = makeApp(false);
547+ var expected = '/foo/bar/';
548+ app.location = {
549+ toString: function() {return 'https://foo.com' + expected;}};
550+ assert.equal(expected, app.get('currentUrl'));
551+ expected = '/';
552+ assert.equal(expected, app.get('currentUrl'));
553+ expected = '/foo/?bar=bing#shazam';
554+ assert.equal(expected, app.get('currentUrl'));
555+ });
556+
557+ it('ignores authtokens', function() {
558+ // This is intended to be the canonical current path. This should
559+ // never include authtokens, which are transient and can never be
560+ // re-used.
561+ var app = makeApp(false);
562+ var expected_path = '/foo/bar/';
563+ var expected_querystring = '';
564+ var expected_hash = '';
565+ var expected = function(add_authtoken) {
566+ var result = expected_path;
567+ var querystring = expected_querystring;
568+ if (add_authtoken) {
569+ querystring += '&authtoken=demoToken';
570+ }
571+ if (querystring) {
572+ result += '?' + querystring;
573+ }
574+ result += expected_hash;
575+ return result;
576+ };
577+ app.location = {
578+ toString: function() {return 'https://foo.com' + expected(true);}};
579+ assert.equal(expected(), app.get('currentUrl'));
580+ expected_path = '/';
581+ assert.equal(expected(), app.get('currentUrl'));
582+ expected_path = '/foo/';
583+ expected_querystring = 'bar=bing';
584+ expected_hash = '#shazam';
585+ assert.equal(expected(), app.get('currentUrl'));
586+ });
587+ });
588+
589 });
590 })();
591
592
593=== modified file 'test/test_env_go.js'
594--- test/test_env_go.js 2013-11-20 18:45:37 +0000
595+++ test/test_env_go.js 2013-11-26 22:03:26 +0000
596@@ -82,7 +82,7 @@
597 });
598
599 describe('Go Juju environment', function() {
600- var conn, endpointA, endpointB, env, juju, msg, utils, Y, oldHandleLogin;
601+ var conn, endpointA, endpointB, env, juju, msg, utils, Y, cleanups;
602
603 before(function(done) {
604 Y = YUI(GlobalConfig).use(['juju-env', 'juju-tests-utils'], function(Y) {
605@@ -98,109 +98,220 @@
606 conn: conn, user: 'user', password: 'password'
607 }, 'go');
608 env.connect();
609+ cleanups = [];
610 });
611
612 afterEach(function() {
613- env.destroy();
614+ cleanups.forEach(function(action) {action();});
615+ // We need to clear any credentials stored in sessionStorage.
616+ env.setCredentials(null);
617+ if (env && env.destroy) {env.destroy();}
618+ if (conn && conn.destroy) {conn.destroy();}
619 });
620
621 var noopHandleLogin = function() {
622- // In order to avoid rewriting all of these go tests we need to destroy
623- // the env created in the beforeEach
624- env.destroy();
625- // We need to clear any credentials stored in sessionStorage.
626- env.setCredentials(null);
627- oldHandleLogin = Y.juju.environments.GoEnvironment.handleLogin;
628+ var oldHandleLogin = Y.juju.environments.GoEnvironment.handleLogin;
629 Y.juju.environments.GoEnvironment.handleLogin = function() {};
630- conn = new utils.SocketStub();
631- env = juju.newEnvironment({
632- conn: conn, user: 'user', password: 'password'
633- }, 'go');
634- env.connect();
635- };
636-
637- var resetHandleLogin = function() {
638- Y.juju.environments.GoEnvironment.handleLogin = oldHandleLogin;
639- };
640-
641- it('sends the correct login message', function() {
642- noopHandleLogin();
643- env.login();
644- var last_message = conn.last_message();
645- var expected = {
646- Type: 'Admin',
647- Request: 'Login',
648- RequestId: 1,
649- Params: {AuthTag: 'user', Password: 'password'}
650- };
651- assert.deepEqual(expected, last_message);
652- resetHandleLogin();
653- });
654-
655- it('resets the user and password if they are not valid', function() {
656- env.login();
657- // Assume login to be the first request.
658- conn.msg({RequestId: 1, Error: 'Invalid user or password'});
659- assert.isNull(env.getCredentials());
660- assert.isTrue(env.failedAuthentication);
661- });
662-
663- it('fires a login event on successful login', function() {
664- var loginFired = false;
665- var result;
666- env.on('login', function(evt) {
667- loginFired = true;
668- result = evt.data.result;
669- });
670- env.login();
671- // Assume login to be the first request.
672- conn.msg({RequestId: 1, Response: {}});
673- assert.isTrue(loginFired);
674- assert.isTrue(result);
675- });
676-
677- it('fires a login event on failed login', function() {
678- var loginFired = false;
679- var result;
680- env.on('login', function(evt) {
681- loginFired = true;
682- result = evt.data.result;
683- });
684- env.login();
685- // Assume login to be the first request.
686- conn.msg({RequestId: 1, Error: 'Invalid user or password'});
687- assert.isTrue(loginFired);
688- assert.isFalse(result);
689- });
690-
691- it('avoids sending login requests without credentials', function() {
692- env.setCredentials(null);
693- env.login();
694- assert.equal(0, conn.messages.length);
695- });
696-
697- it('calls environmentInfo and watchAll ofter login', function() {
698- env.login();
699- // Assume login to be the first request.
700- conn.msg({RequestId: 1, Response: {}});
701- var environmentInfoMessage = conn.last_message(2);
702- // EnvironmentInfo is the second request.
703- var environmentInfoExpected = {
704- Type: 'Client',
705- Request: 'EnvironmentInfo',
706- RequestId: 2,
707- Params: {}
708- };
709- assert.deepEqual(environmentInfoExpected, environmentInfoMessage);
710- var watchAllMessage = conn.last_message();
711- // EnvironmentInfo is the second request.
712- var watchAllExpected = {
713- Type: 'Client',
714- Request: 'WatchAll',
715- RequestId: 3,
716- Params: {}
717- };
718- assert.deepEqual(watchAllExpected, watchAllMessage);
719+ cleanups.push(function() {
720+ Y.juju.environments.GoEnvironment.handleLogin = oldHandleLogin;
721+ });
722+ };
723+
724+ describe('login', function() {
725+ it('sends the correct login message', function() {
726+ noopHandleLogin();
727+ env.login();
728+ var last_message = conn.last_message();
729+ var expected = {
730+ Type: 'Admin',
731+ Request: 'Login',
732+ RequestId: 1,
733+ Params: {AuthTag: 'user', Password: 'password'}
734+ };
735+ assert.deepEqual(expected, last_message);
736+ });
737+
738+ it('resets the user and password if they are not valid', function() {
739+ env.login();
740+ // Assume login to be the first request.
741+ conn.msg({RequestId: 1, Error: 'Invalid user or password'});
742+ assert.isNull(env.getCredentials());
743+ assert.isTrue(env.failedAuthentication);
744+ assert.isFalse(env.failedTokenAuthentication);
745+ });
746+
747+ it('fires a login event on successful login', function() {
748+ var loginFired = false;
749+ var result, fromToken;
750+ env.on('login', function(evt) {
751+ loginFired = true;
752+ result = evt.data.result;
753+ fromToken = evt.data.fromToken;
754+ });
755+ env.login();
756+ // Assume login to be the first request.
757+ conn.msg({RequestId: 1, Response: {}});
758+ assert.isTrue(loginFired);
759+ assert.isTrue(result);
760+ assert.isFalse(fromToken);
761+ });
762+
763+ it('resets failed markers on successful login', function() {
764+ env.failedAuthentication = env.failedTokenAuthentication = true;
765+ env.login();
766+ // Assume login to be the first request.
767+ conn.msg({RequestId: 1, Response: {}});
768+ assert.isFalse(env.failedAuthentication);
769+ assert.isFalse(env.failedTokenAuthentication);
770+ });
771+
772+ it('fires a login event on failed login', function() {
773+ var loginFired = false;
774+ var result;
775+ env.on('login', function(evt) {
776+ loginFired = true;
777+ result = evt.data.result;
778+ });
779+ env.login();
780+ // Assume login to be the first request.
781+ conn.msg({RequestId: 1, Error: 'Invalid user or password'});
782+ assert.isTrue(loginFired);
783+ assert.isFalse(result);
784+ });
785+
786+ it('avoids sending login requests without credentials', function() {
787+ env.setCredentials(null);
788+ env.login();
789+ assert.equal(0, conn.messages.length);
790+ });
791+
792+ it('calls environmentInfo and watchAll ofter login', function() {
793+ env.login();
794+ // Assume login to be the first request.
795+ conn.msg({RequestId: 1, Response: {}});
796+ var environmentInfoMessage = conn.last_message(2);
797+ // EnvironmentInfo is the second request.
798+ var environmentInfoExpected = {
799+ Type: 'Client',
800+ Request: 'EnvironmentInfo',
801+ RequestId: 2,
802+ Params: {}
803+ };
804+ assert.deepEqual(environmentInfoExpected, environmentInfoMessage);
805+ var watchAllMessage = conn.last_message();
806+ // EnvironmentInfo is the second request.
807+ var watchAllExpected = {
808+ Type: 'Client',
809+ Request: 'WatchAll',
810+ RequestId: 3,
811+ Params: {}
812+ };
813+ assert.deepEqual(watchAllExpected, watchAllMessage);
814+ });
815+ });
816+
817+ describe('tokenLogin', function() {
818+ it('sends the correct tokenLogin message', function() {
819+ noopHandleLogin();
820+ env.tokenLogin('demoToken');
821+ var last_message = conn.last_message();
822+ var expected = {
823+ Type: 'GUIToken',
824+ Request: 'Login',
825+ RequestId: 1,
826+ Params: {Token: 'demoToken'}
827+ };
828+ assert.deepEqual(expected, last_message);
829+ });
830+
831+ it('resets the user and password if the token is not valid', function() {
832+ env.tokenLogin('badToken');
833+ // Assume login to be the first request.
834+ conn.msg({
835+ RequestId: 1,
836+ Error: 'unknown, fulfilled, or expired token',
837+ ErrorCode: 'unauthorized access'
838+ });
839+ assert.isNull(env.getCredentials());
840+ assert.isTrue(env.failedTokenAuthentication);
841+ assert.isFalse(env.failedAuthentication);
842+ });
843+
844+ it('fires a login event on successful token login', function() {
845+ var loginFired = false;
846+ var result, fromToken;
847+ env.on('login', function(evt) {
848+ loginFired = true;
849+ result = evt.data.result;
850+ fromToken = evt.data.fromToken;
851+ });
852+ env.tokenLogin('demoToken');
853+ // Assume login to be the first request.
854+ conn.msg({
855+ RequestId: 1,
856+ Response: {AuthTag: 'tokenuser', Password: 'tokenpasswd'}});
857+ assert.isTrue(loginFired);
858+ assert.isTrue(result);
859+ assert.isTrue(fromToken);
860+ var credentials = env.getCredentials();
861+ assert.equal('tokenuser', credentials.user);
862+ assert.equal('tokenpasswd', credentials.password);
863+ });
864+
865+ it('resets failed markers on successful login', function() {
866+ env.failedAuthentication = env.failedTokenAuthentication = true;
867+ env.tokenLogin('demoToken');
868+ // Assume login to be the first request.
869+ conn.msg({
870+ RequestId: 1,
871+ Response: {AuthTag: 'tokenuser', Password: 'tokenpasswd'}});
872+ assert.isFalse(env.failedAuthentication);
873+ assert.isFalse(env.failedTokenAuthentication);
874+ });
875+
876+ it('fires a login event on failed token login', function() {
877+ var loginFired = false;
878+ var result;
879+ env.on('login', function(evt) {
880+ loginFired = true;
881+ result = evt.data.result;
882+ });
883+ env.tokenLogin('badToken');
884+ // Assume login to be the first request.
885+ conn.msg({
886+ RequestId: 1,
887+ Error: 'unknown, fulfilled, or expired token',
888+ ErrorCode: 'unauthorized access'
889+ });
890+ assert.isTrue(loginFired);
891+ assert.isFalse(result);
892+ });
893+
894+ it('calls environmentInfo and watchAll ofter token login', function() {
895+ env.tokenLogin('demoToken');
896+ // Assume login to be the first request.
897+ conn.msg({
898+ RequestId: 1,
899+ Response: {AuthTag: 'tokenuser', Password: 'tokenpasswd'}});
900+ var environmentInfoMessage = conn.last_message(2);
901+ // EnvironmentInfo is the second request.
902+ var environmentInfoExpected = {
903+ Type: 'Client',
904+ Request: 'EnvironmentInfo',
905+ RequestId: 2,
906+ Params: {}
907+ };
908+ assert.deepEqual(environmentInfoExpected, environmentInfoMessage);
909+ var watchAllMessage = conn.last_message();
910+ // EnvironmentInfo is the second request.
911+ var watchAllExpected = {
912+ Type: 'Client',
913+ Request: 'WatchAll',
914+ RequestId: 3,
915+ Params: {}
916+ };
917+ assert.deepEqual(watchAllExpected, watchAllMessage);
918+ });
919 });
920
921 it('sends the correct request for environment info', function() {
922
923=== modified file 'test/test_fakebackend.js'
924--- test/test_fakebackend.js 2013-11-20 19:38:03 +0000
925+++ test/test_fakebackend.js 2013-11-26 22:03:26 +0000
926@@ -60,6 +60,37 @@
927 });
928 });
929
930+ describe('FakeBackend.tokenlogin', function() {
931+ var requires = ['node', 'juju-env-fakebackend'];
932+ var Y, environmentsModule, fakebackend;
933+
934+ before(function(done) {
935+ Y = YUI(GlobalConfig).use(requires, function(Y) {
936+ environmentsModule = Y.namespace('juju.environments');
937+ done();
938+ });
939+ });
940+
941+ afterEach(function() {
942+ fakebackend.destroy();
943+ });
944+
945+ it('authenticates', function() {
946+ fakebackend = new environmentsModule.FakeBackend();
947+ assert.equal(fakebackend.get('authenticated'), false);
948+ assert.deepEqual(
949+ fakebackend.tokenlogin('demoToken'), ['admin', 'password']);
950+ assert.equal(fakebackend.get('authenticated'), true);
951+ });
952+
953+ it('refuses to authenticate', function() {
954+ fakebackend = new environmentsModule.FakeBackend();
955+ assert.equal(fakebackend.get('authenticated'), false);
956+ assert.isUndefined(fakebackend.tokenlogin('not the token'));
957+ assert.equal(fakebackend.get('authenticated'), false);
958+ });
959+ });
960+
961 describe('FakeBackend.deploy', function() {
962 var requires = [
963 'node', 'juju-tests-utils', 'juju-models', 'juju-charm-models'];
964
965=== modified file 'test/test_login.js'
966--- test/test_login.js 2013-08-27 16:24:00 +0000
967+++ test/test_login.js 2013-11-26 22:03:26 +0000
968@@ -44,6 +44,7 @@
969 env.destroy();
970 });
971
972+ // These duplicate more thorough tests in test_env_go.js.
973 test('the user is initially assumed to be unauthenticated', function() {
974 assert.isFalse(env.userIsAuthenticated);
975 });
976
977=== modified file 'test/test_sandbox_go.js'
978--- test/test_sandbox_go.js 2013-11-20 18:45:37 +0000
979+++ test/test_sandbox_go.js 2013-11-26 22:03:26 +0000
980@@ -45,6 +45,8 @@
981 });
982
983 afterEach(function() {
984+ // We need to clear any credentials stored in sessionStorage.
985+ env.setCredentials(null);
986 env.destroy();
987 client.destroy();
988 juju.destroy();
989@@ -154,6 +156,91 @@
990 env.login();
991 });
992
993+ it('can log in with a token.', function(done) {
994+ // See FakeBackend's initialization for these default authentication
995+ // values.
996+ var data = {
997+ Type: 'GUIToken',
998+ Request: 'Login',
999+ Params: {
1000+ Token: 'demoToken'
1001+ },
1002+ RequestId: 42
1003+ };
1004+ client.onmessage = function(received) {
1005+ var expected = {
1006+ RequestId: 42, Response: {AuthTag: 'admin', Password: 'password'}};
1007+ assert.deepEqual(Y.JSON.parse(received.data), expected);
1008+ assert.isTrue(state.get('authenticated'));
1009+ done();
1010+ };
1011+ state.logout();
1012+ assert.isFalse(state.get('authenticated'));
1013+ client.open();
1014+ client.send(Y.JSON.stringify(data));
1015+ });
1016+
1017+ it('does not log in with a bad token.', function(done) {
1018+ // See FakeBackend's initialization for these default authentication
1019+ // values.
1020+ var data = {
1021+ Type: 'GUIToken',
1022+ Request: 'Login',
1023+ Params: {
1024+ Token: 'badToken'
1025+ },
1026+ RequestId: 42
1027+ };
1028+ client.onmessage = function(received) {
1029+ var expected = {
1030+ RequestId: 42,
1031+ Error: 'unknown, fulfilled, or expired token',
1032+ ErrorCode: 'unauthorized access',
1033+ Response: {}};
1034+ assert.deepEqual(Y.JSON.parse(received.data), expected);
1035+ assert.isFalse(state.get('authenticated'));
1036+ done();
1037+ };
1038+ state.logout();
1039+ assert.isFalse(state.get('authenticated'));
1040+ client.open();
1041+ client.send(Y.JSON.stringify(data));
1042+ });
1043+
1044+ it('can log in with a token (environment integration).', function(done) {
1045+ state.logout();
1046+ env.after('login', function() {
1047+ assert.isTrue(env.userIsAuthenticated);
1048+ assert.deepEqual(env.getCredentials(),
1049+ {user: 'admin', password: 'password'});
1050+ done();
1051+ });
1052+ env.connect();
1053+ assert.isFalse(env.getCredentials().areAvailable);
1054+ env.tokenLogin('demoToken');
1055+ });
1056+
1057+ it('can return environment information.', function(done) {
1058+ // See FakeBackend's initialization for these default values.
1059+ var data = {
1060+ Type: 'Client',
1061+ Request: 'EnvironmentInfo',
1062+ RequestId: 42
1063+ };
1064+ client.onmessage = function(received) {
1065+ var expected = {
1066+ RequestId: 42,
1067+ Response: {
1068+ ProviderType: state.get('providerType'),
1069+ DefaultSeries: state.get('defaultSeries'),
1070+ Name: 'Sandbox'}};
1071+ assert.deepEqual(Y.JSON.parse(received.data), expected);
1072+ done();
1073+ };
1074+ client.open();
1075+ client.send(Y.JSON.stringify(data));
1076+ });
1077+
1078 it('can start the AllWatcher', function(done) {
1079 var data = {
1080 Type: 'Client',
1081
1082=== modified file 'test/utils.js'
1083--- test/utils.js 2013-11-21 15:04:59 +0000
1084+++ test/utils.js 2013-11-26 22:03:26 +0000
1085@@ -24,6 +24,77 @@
1086
1087 jujuTests.utils = {
1088
1089+ /**
1090+ * Make a stub function. Pass in 0 or more arguments to become responses
1091+ * that the function cycles through.
1092+ *
1093+ * @method makeStubFunction
1094+ * @return {Function} the new stub function.
1095+ */
1096+ makeStubFunction: function() {
1097+ var responses = Array.prototype.slice.call(arguments, 0);
1098+ if (responses.length === 0) {
1099+ responses.push(undefined);
1100+ }
1101+ var f = function() {
1102+ var response = responses[(f._allArguments.length) % responses.length];
1103+ f._allArguments.push(Array.prototype.slice.call(arguments, 0));
1104+ f._callbacks.forEach(function(cb) {cb.call(f);});
1105+ return response;
1106+ };
1107+ f._allArguments = [];
1108+ f._callbacks = [];
1109+ f.called = function() {
1110+ return !!f._allArguments.length;
1111+ };
1112+ f.calledOnce = function() {
1113+ return f._allArguments.length === 1;
1114+ };
1115+ f.callCount = function() {
1116+ return f._allArguments.length;
1117+ };
1118+ f.lastArguments = function() {
1119+ return f._allArguments[f._allArguments.length - 1];
1120+ };
1121+ f.allArguments = function() {
1122+ return f._allArguments.slice(0);
1123+ };
1124+ f.addCallback = function(cb) {
1125+ f._callbacks.push(cb);
1126+ };
1127+ return f;
1128+ },
1129+
1130+ /**
1131+ * Make a stub method. Pass in 0 or more arguments to become responses
1132+ * that the method cycles through.
1133+ *
1134+ * The function has all introspection methods from makeMockFunction,
1135+ * plus "reset", which resets the object with the original value.
1136+ * This is pre-bound, so it is easy to pass in as a clean-up function.
1137+ *
1138+ * @method makeStubMethod
1139+ * @param {Object} context the object on which the method will sit.
1140+ * @param {String} name the name to be replaced.
1141+ * @return {Function} the new stub function.
1142+ */
1143+ makeStubMethod: function(context, name) {
1144+ var responses = Array.prototype.slice.call(arguments, 2);
1145+ var original = context[name];
1146+ var f = context[name] = jujuTests.utils.makeStubFunction.apply(
1147+ jujuTests.utils, responses);
1148+ f.reset = function() {
1149+ context[name] = original;
1150+ };
1151+ f.passThroughToOriginalMethod = function(instance) {
1152+ if (!Y.Lang.isValue(instance)) {
1153+ instance = context;
1154+ }
1155+ return original.apply(instance, f.lastArguments());
1156+ };
1157+ return f;
1158+ },
1159+
1160 makeContainer: function(id, visibleContainer) {
1161 var container = Y.Node.create('<div>');
1162 if (id) {

Subscribers

People subscribed via source and target branches