Merge lp:~frankban/charms/precise/juju-gui/server-auth into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 79
Proposed branch: lp:~frankban/charms/precise/juju-gui/server-auth
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 1183 lines (+807/-62)
12 files modified
revision (+1/-1)
server/guiserver/apps.py (+9/-3)
server/guiserver/auth.py (+215/-0)
server/guiserver/handlers.py (+18/-4)
server/guiserver/manage.py (+27/-3)
server/guiserver/tests/helpers.py (+78/-0)
server/guiserver/tests/test_auth.py (+231/-0)
server/guiserver/tests/test_handlers.py (+140/-40)
server/guiserver/tests/test_manage.py (+38/-10)
server/guiserver/tests/test_utils.py (+25/-0)
server/guiserver/utils.py (+23/-0)
server/runserver.py (+2/-1)
To merge this branch: bzr merge lp:~frankban/charms/precise/juju-gui/server-auth
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+176738@code.launchpad.net

Description of the change

GUI server authentication.

Added authentication management to the Tornado server.
The WebSocket handler includes a way to know if the
connected client is logged in to the API server.
Both Go and Python API implementations are supported.

Also implemented WebSocket messages validation.

I am sorry: this diff is quite long.
In my defence, the code is full of tests and
documentation... Well, sorry again.

Tests: `make unittest`

https://codereview.appspot.com/11725044/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+176738_code.launchpad.net,

Message:
Please take a look.

Description:
GUI server authentication.

Added authentication management to the Tornado server.
The WebSocket handler includes a way to know if the
connected client is logged in to the API server.
Both Go and Python API implementations are supported.

Also implemented WebSocket messages validation.

I am sorry: this diff is quite long.
In my defence, the code is full of tests and
documentation... Well, sorry again.

Tests: `make unittest`

https://code.launchpad.net/~frankban/charms/precise/juju-gui/server-auth/+merge/176738

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/11725044/

Affected files:
   A [revision details]
   M revision
   M server/guiserver/apps.py
   A server/guiserver/auth.py
   M server/guiserver/handlers.py
   M server/guiserver/manage.py
   M server/guiserver/tests/helpers.py
   A server/guiserver/tests/test_auth.py
   M server/guiserver/tests/test_handlers.py
   M server/guiserver/tests/test_manage.py
   M server/guiserver/tests/test_utils.py
   M server/guiserver/utils.py
   M server/runserver.py

Revision history for this message
Benjamin Saller (bcsaller) wrote :

My prelim look over the code was nice. make unittests fails with various
modules apparently missing. What is missing seems to vary from run to
run. I've missed shelltoolbox (from charmtools helpers import) but on
other runs apt, yaml. sometimes they load in.

Have you seen this? I didn't spend too long investigating but when I
hear back from you I can try it again.

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_handlers.py
File server/guiserver/tests/test_handlers.py (left):

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_handlers.py#oldcode82
server/guiserver/tests/test_handlers.py:82:
Abstract.

https://codereview.appspot.com/11725044/

Revision history for this message
Francesco Banconi (frankban) wrote :

On 2013/07/24 21:33:11, benjamin.saller wrote:
> My prelim look over the code was nice. make unittests fails with
various modules
> apparently missing. What is missing seems to vary from run to run.
I've missed
> shelltoolbox (from charmtools helpers import) but on other runs apt,
yaml.
> sometimes they load in.

> Have you seen this? I didn't spend too long investigating but when I
hear back
> from you I can try it again.

No I don't. It seems the virtualenv for tests is not being created
correctly.
Could you please try the following, from the branch root?
make clean # delete the venv
make # network errors?
make unittest

If the problem is still there, could you please paste the output of
`./tests/.venv/bin/pip freeze`? Thanks.

https://codereview.appspot.com/11725044/

86. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

Please take a look.

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_manage.py
File server/guiserver/tests/test_manage.py (right):

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_manage.py#newcode116
server/guiserver/tests/test_manage.py:116: with
mock.patch('guiserver.manage.options', {'arg1': None}):
On 2013/07/25 13:20:59, benji wrote:
> I think this is the only test method that you didn't include a comment
for. :)

Done.

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_utils.py
File server/guiserver/tests/test_utils.py (right):

https://codereview.appspot.com/11725044/diff/1/server/guiserver/tests/test_utils.py#newcode60
server/guiserver/tests/test_utils.py:60: # if the resulting object is
not a dict-like object, a warning is
On 2013/07/25 13:20:59, benji wrote:
> There is an extra space at the beginning of the comment and "if"
should be
> capitalized.

Done.

https://codereview.appspot.com/11725044/

Revision history for this message
Nicola Larosa (teknico) wrote :

LGTM, impressive work indeed. One trivial below. Also, consider only
saying "log in" and "logged in" when used as a verb, but "login" when a
noun, i.e. "login request".

https://codereview.appspot.com/11725044/diff/9001/server/guiserver/manage.py
File server/guiserver/manage.py (right):

https://codereview.appspot.com/11725044/diff/9001/server/guiserver/manage.py#newcode86
server/guiserver/manage.py:86: 'of the bootstrap/state node as returned
by juju status.')
Double quotes around "juju status", maybe?

https://codereview.appspot.com/11725044/

87. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :

Thank you both for the reviews!

> LGTM, impressive work indeed. One trivial below. Also, consider only
saying "log
> in" and "logged in" when used as a verb, but "login" when a noun, i.e.
"login
> request".

Done.

https://codereview.appspot.com/11725044/diff/9001/server/guiserver/manage.py#newcode86
> server/guiserver/manage.py:86: 'of the bootstrap/state node as
returned by juju
> status.')
> Double quotes around "juju status", maybe?

Done.

https://codereview.appspot.com/11725044/

Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

GUI server authentication.

Added authentication management to the Tornado server.
The WebSocket handler includes a way to know if the
connected client is logged in to the API server.
Both Go and Python API implementations are supported.

Also implemented WebSocket messages validation.

I am sorry: this diff is quite long.
In my defence, the code is full of tests and
documentation... Well, sorry again.

Tests: `make unittest`

R=benjamin.saller, benji, teknico
CC=
https://codereview.appspot.com/11725044

https://codereview.appspot.com/11725044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'revision'
2--- revision 2013-07-23 08:21:47 +0000
3+++ revision 2013-07-25 15:44:30 +0000
4@@ -1,1 +1,1 @@
5-60
6+61
7
8=== modified file 'server/guiserver/apps.py'
9--- server/guiserver/apps.py 2013-07-19 10:25:55 +0000
10+++ server/guiserver/apps.py 2013-07-25 15:44:30 +0000
11@@ -21,7 +21,10 @@
12 from tornado import web
13 from tornado.options import options
14
15-from guiserver import handlers
16+from guiserver import (
17+ auth,
18+ handlers,
19+)
20
21
22 def server():
23@@ -30,17 +33,20 @@
24 The server app is responsible for serving the WebSocket connection, the
25 Juju GUI static files and the main index file for dynamic URLs.
26 """
27+ # Set up static paths.
28 guiroot = options.guiroot
29 static_path = os.path.join(guiroot, 'juju-ui')
30+ # Set up the authentication backend.
31+ auth_backend = auth.get_backend(options.apiversion)
32 return web.Application([
33 # Handle WebSocket connections.
34- (r'^/ws$', handlers.WebSocketHandler, {'jujuapi': options.jujuapi}),
35+ (r'^/ws$', handlers.WebSocketHandler, {'apiurl': options.apiurl}),
36 # Handle static files.
37 (r'^/juju-ui/(.*)', web.StaticFileHandler, {'path': static_path}),
38 (r'^/(favicon\.ico)$', web.StaticFileHandler, {'path': guiroot}),
39 # Any other path is served by index.html.
40 (r'^/(.*)', handlers.IndexHandler, {'path': guiroot}),
41- ], debug=options.debug)
42+ ], debug=options.debug, auth_backend=auth_backend)
43
44
45 def redirector():
46
47=== added file 'server/guiserver/auth.py'
48--- server/guiserver/auth.py 1970-01-01 00:00:00 +0000
49+++ server/guiserver/auth.py 2013-07-25 15:44:30 +0000
50@@ -0,0 +1,215 @@
51+# This file is part of the Juju GUI, which lets users view and manage Juju
52+# environments within a graphical interface (https://launchpad.net/juju-gui).
53+# Copyright (C) 2013 Canonical Ltd.
54+#
55+# This program is free software: you can redistribute it and/or modify it under
56+# the terms of the GNU Affero General Public License version 3, as published by
57+# the Free Software Foundation.
58+#
59+# This program is distributed in the hope that it will be useful, but WITHOUT
60+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
61+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
62+# Affero General Public License for more details.
63+#
64+# You should have received a copy of the GNU Affero General Public License
65+# along with this program. If not, see <http://www.gnu.org/licenses/>.
66+
67+"""Juju GUI server authentication management.
68+
69+This module includes the pieces required to process user authentication:
70+
71+ - User: a simple data structure representing a logged in or anonymous user;
72+ - authentication backends (GoBackend and PythonBackend): any object
73+ implementing the following interface:
74+ - get_request_id(data);
75+ - request_is_login(data);
76+ - get_credentials(data);
77+ - login_succeeded(data).
78+ The only purpose of auth backends is to provide the logic to parse
79+ requests' data based on the API implementation currently in use. Backends
80+ don't know anything about the authentication process or the current user,
81+ and are not intended to store state: one backend (the one suitable for
82+ the current API implementation) is instantiated once when the application
83+ is bootstrapped and used as a singleton by all WebSocket requests;
84+ - AuthMiddleware: process authentication requests and responses, using
85+ the backend to parse the WebSocket messages, logging in the current user
86+ if the authentication succeeds.
87+"""
88+
89+
90+class User(object):
91+ """The current WebSocket user."""
92+
93+ def __init__(self, username='', password='', is_authenticated=False):
94+ self.is_authenticated = is_authenticated
95+ # XXX (frankban) YAGNI: the username/password attributes are not
96+ # required for now, but they will help handling the HA story, i.e. in
97+ # the process of re-authenticating to the API after switching from one
98+ # Juju state/API server to another.
99+ self.username = username
100+ self.password = password
101+
102+ def __repr__(self):
103+ if self.is_authenticated:
104+ status = 'authenticated'
105+ else:
106+ status = 'not authenticated'
107+ username = self.username or 'anonymous'
108+ return '<User: {} ({})>'.format(username, status)
109+
110+
111+class AuthMiddleware(object):
112+ """Handle user authentication.
113+
114+ This class handles the process of authenticating the provided user using
115+ the given auth backend. Note that, since the GUI just disconnects when the
116+ user logs out, there is no need to handle the log out process.
117+ """
118+
119+ def __init__(self, user, backend):
120+ self._user = user
121+ self._backend = backend
122+ self._request_id = None
123+
124+ def in_progress(self):
125+ """Return True if the authentication is in progress, False otherwise.
126+ """
127+ return self._request_id is not None
128+
129+ def process_request(self, data):
130+ """Parse the WebSocket data arriving from the client.
131+
132+ Start the authentication process if data represents a login request
133+ performed by the GUI user.
134+ """
135+ backend = self._backend
136+ request_id = backend.get_request_id(data)
137+ if request_id and backend.request_is_login(data):
138+ self._request_id = request_id
139+ credentials = backend.get_credentials(data)
140+ self._user.username, self._user.password = credentials
141+
142+ def process_response(self, data):
143+ """Parse the WebSocket data arriving from the Juju API server.
144+
145+ Complete the authentication process if data represents the response
146+ to a login request previously initiated. Authenticate the user if the
147+ authentication succeeded.
148+ """
149+ request_id = self._backend.get_request_id(data)
150+ if request_id == self._request_id:
151+ logged_in = self._backend.login_succeeded(data)
152+ if logged_in:
153+ self._user.is_authenticated = True
154+ else:
155+ self._user.username = self._user.password = ''
156+ self._request_id = None
157+
158+
159+class GoBackend(object):
160+ """Authentication backend for the Juju Go API implementation.
161+
162+ A login request looks like the following:
163+
164+ {
165+ 'RequestId': 42,
166+ 'Type': 'Admin',
167+ 'Request': 'Login',
168+ 'Params': {'AuthTag': 'user-admin', 'Password': 'ADMIN-SECRET'},
169+ }
170+
171+ Here is an example of a successful login response:
172+
173+ {'RequestId': 42, 'Response': {}}
174+
175+ A login failure response is like the following:
176+
177+ {
178+ 'RequestId': 42,
179+ 'Error': 'invalid entity name or password',
180+ 'ErrorCode': 'unauthorized access',
181+ 'Response': {},
182+ }
183+ """
184+
185+ def get_request_id(self, data):
186+ """Return the request identifier associated with the provided data."""
187+ return data.get('RequestId')
188+
189+ def request_is_login(self, data):
190+ """Return True if data represents a login request, False otherwise."""
191+ params = data.get('Params', {})
192+ return (
193+ data.get('Type') == 'Admin' and
194+ data.get('Request') == 'Login' and
195+ 'AuthTag' in params and
196+ 'Password' in params
197+ )
198+
199+ def get_credentials(self, data):
200+ """Parse the provided login data and return username and password."""
201+ params = data['Params']
202+ return params['AuthTag'], params['Password']
203+
204+ def login_succeeded(self, data):
205+ """Return True if data represents a successful login, False otherwise.
206+ """
207+ return 'Error' not in data
208+
209+
210+class PythonBackend(object):
211+ """Authentication backend for the Juju Python implementation.
212+
213+ A login request looks like the following:
214+
215+ {
216+ 'request_id': 42,
217+ 'op': 'login',
218+ 'user': 'admin',
219+ 'password': 'ADMIN-SECRET',
220+ }
221+
222+ A successful login response includes these fields:
223+
224+ {
225+ 'request_id': 42,
226+ 'op': 'login',
227+ 'user': 'admin',
228+ 'password': 'ADMIN-SECRET',
229+ 'result': True,
230+ }
231+
232+ A login failure response is like the following:
233+
234+ {
235+ 'request_id': 42,
236+ 'op': 'login',
237+ 'user': 'admin',
238+ 'password': 'ADMIN-SECRET',
239+ 'err': True,
240+ }
241+ """
242+
243+ def get_request_id(self, data):
244+ """Return the request identifier associated with the provided data."""
245+ return data.get('request_id')
246+
247+ def request_is_login(self, data):
248+ """Return True if data represents a login request, False otherwise."""
249+ op = data.get('op')
250+ return (op == 'login') and ('user' in data) and ('password' in data)
251+
252+ def get_credentials(self, data):
253+ """Parse the provided login data and return username and password."""
254+ return data['user'], data['password']
255+
256+ def login_succeeded(self, data):
257+ """Return True if data represents a successful login, False otherwise.
258+ """
259+ return data.get('result') and not data.get('err')
260+
261+
262+def get_backend(apiversion):
263+ """Return the auth backend instance to use for the given API version."""
264+ backend_class = {'go': GoBackend, 'python': PythonBackend}[apiversion]
265+ return backend_class()
266
267=== modified file 'server/guiserver/handlers.py'
268--- server/guiserver/handlers.py 2013-07-23 08:21:12 +0000
269+++ server/guiserver/handlers.py 2013-07-25 15:44:30 +0000
270@@ -27,9 +27,14 @@
271 )
272 from tornado.ioloop import IOLoop
273
274+from guiserver.auth import (
275+ AuthMiddleware,
276+ User,
277+)
278 from guiserver.clients import websocket_connect
279 from guiserver.utils import (
280 get_headers,
281+ json_decode_dict,
282 request_summary,
283 )
284
285@@ -56,11 +61,10 @@
286 Methods:
287 - write_message(message): send a message to the browser;
288 - close(): terminate the browser connection.
289-
290 """
291
292 @gen.coroutine
293- def initialize(self, jujuapi, io_loop=None):
294+ def initialize(self, apiurl, io_loop=None):
295 """Create a new WebSocket client and connect it to the Juju API."""
296 if io_loop is None:
297 io_loop = IOLoop.current()
298@@ -70,13 +74,17 @@
299 self.connected = True
300 self.juju_connected = False
301 self._juju_message_queue = queue = deque()
302+ # Set up the authentication infrastructure.
303+ self.user = User()
304+ auth_backend = self.application.settings['auth_backend']
305+ self.auth = AuthMiddleware(self.user, auth_backend)
306 # Juju requires the Origin header to be included in the WebSocket
307 # client handshake request. Propagate the client origin if present;
308 # use the Juju API server as origin otherwise.
309- headers = get_headers(self.request, jujuapi)
310+ headers = get_headers(self.request, apiurl)
311 # Connect the WebSocket client to the Juju API server.
312 self._juju_connected_future = websocket_connect(
313- io_loop, jujuapi, self.on_juju_message, headers=headers)
314+ io_loop, apiurl, self.on_juju_message, headers=headers)
315 try:
316 self.juju_connection = yield self._juju_connected_future
317 except Exception as err:
318@@ -101,6 +109,9 @@
319 Messages sent before the client connection to the Juju API server is
320 established are queued for later delivery.
321 """
322+ data = json_decode_dict(message)
323+ if (data is not None) and (not self.user.is_authenticated):
324+ self.auth.process_request(data)
325 if self.juju_connected:
326 logging.debug(self._summary + 'client -> juju: {}'.format(message))
327 return self.juju_connection.write_message(message)
328@@ -115,6 +126,9 @@
329 if message is None:
330 # The Juju API closed the connection.
331 return self.on_juju_close()
332+ data = json_decode_dict(message)
333+ if (data is not None) and self.auth.in_progress():
334+ self.auth.process_response(data)
335 logging.debug(self._summary + 'juju -> client: {}'.format(message))
336 self.write_message(message)
337
338
339=== modified file 'server/guiserver/manage.py'
340--- server/guiserver/manage.py 2013-07-17 15:21:26 +0000
341+++ server/guiserver/manage.py 2013-07-25 15:44:30 +0000
342@@ -33,6 +33,7 @@
343 )
344
345
346+DEFAULT_API_VERSION = 'go'
347 SSL_OPTIONS = {
348 'certfile': '/etc/ssl/juju-gui/juju.crt',
349 'keyfile': '/etc/ssl/juju-gui/juju.key',
350@@ -62,13 +63,36 @@
351 sys.exit('error: the {} argument is required'.format(name))
352
353
354+def _validate_choices(option_name, choices):
355+ """Ensure the value passed for the given option is included in the choices.
356+
357+ Exit with an error if the value is not in the accepted ones.
358+ """
359+ value = options[option_name]
360+ if value not in choices:
361+ sys.exit('error: accepted values for the {} argument are: {}'.format(
362+ option_name, ', '.join(choices)))
363+
364+
365 def setup():
366 """Set up options and logger."""
367- define('guiroot', type=str, help='the Juju GUI static files path')
368- define('jujuapi', type=str, help='the Juju WebSocket server address')
369+ define(
370+ 'guiroot', type=str,
371+ help='The Juju GUI static files path, e.g.: '
372+ '/var/lib/juju/agents/unit-juju-gui-0/charm/juju-gui/build-prod')
373+ define(
374+ 'apiurl', type=str,
375+ help='The Juju WebSocket server address. This is usually the address '
376+ 'of the bootstrap/state node as returned by "juju status".')
377+ # Optional parameters.
378+ define(
379+ 'apiversion', type=str, default=DEFAULT_API_VERSION,
380+ help='the Juju API version/implementation. Currently the possible '
381+ 'values are "go" (default) or "python".')
382 # In Tornado, parsing the options also sets up the default logger.
383 parse_command_line()
384- _validate_required('guiroot', 'jujuapi')
385+ _validate_required('guiroot', 'apiurl')
386+ _validate_choices('apiversion', ('go', 'python'))
387 _add_debug(logging.getLogger())
388
389
390
391=== modified file 'server/guiserver/tests/helpers.py'
392--- server/guiserver/tests/helpers.py 2013-07-23 08:21:12 +0000
393+++ server/guiserver/tests/helpers.py 2013-07-25 15:44:30 +0000
394@@ -16,8 +16,12 @@
395
396 """Juju GUI server test utilities."""
397
398+import json
399+
400 from tornado import websocket
401
402+from guiserver import auth
403+
404
405 class EchoWebSocketHandler(websocket.WebSocketHandler):
406 """A WebSocket server echoing back messages."""
407@@ -50,6 +54,80 @@
408 self._closed_future.set_result(None)
409
410
411+class GoAPITestMixin(object):
412+ """Add helper methods for testing the Go API implementation."""
413+
414+ def get_auth_backend(self):
415+ """Return an authentication backend suitable for the Go API."""
416+ return auth.get_backend('go')
417+
418+ def make_login_request(
419+ self, request_id=42, username='user', password='passwd',
420+ encoded=False):
421+ """Create and return a login request message.
422+
423+ If encoded is set to True, the returned message will be JSON encoded.
424+ """
425+ data = {
426+ 'RequestId': request_id,
427+ 'Type': 'Admin',
428+ 'Request': 'Login',
429+ 'Params': {'AuthTag': username, 'Password': password},
430+ }
431+ return json.dumps(data) if encoded else data
432+
433+ def make_login_response(
434+ self, request_id=42, successful=True, encoded=False):
435+ """Create and return a login response message.
436+
437+ If encoded is set to True, the returned message will be JSON encoded.
438+ By default, a successful response is returned. Set successful to False
439+ to return an authentication failure.
440+ """
441+ data = {'RequestId': request_id, 'Response': {}}
442+ if not successful:
443+ data['Error'] = 'invalid entity name or password'
444+ return json.dumps(data) if encoded else data
445+
446+
447+class PythonAPITestMixin(object):
448+ """Add helper methods for testing the Python API implementation."""
449+
450+ def get_auth_backend(self):
451+ """Return an authentication backend suitable for the Python API."""
452+ return auth.get_backend('python')
453+
454+ def make_login_request(
455+ self, request_id=42, username='user', password='passwd',
456+ encoded=False):
457+ """Create and return a login request message.
458+
459+ If encoded is set to True, the returned message will be JSON encoded.
460+ """
461+ data = {
462+ 'request_id': request_id,
463+ 'op': 'login',
464+ 'user': username,
465+ 'password': password,
466+ }
467+ return json.dumps(data) if encoded else data
468+
469+ def make_login_response(
470+ self, request_id=42, successful=True, encoded=False):
471+ """Create and return a login response message.
472+
473+ If encoded is set to True, the returned message will be JSON encoded.
474+ By default, a successful response is returned. Set successful to False
475+ to return an authentication failure.
476+ """
477+ data = {'request_id': request_id, 'op': 'login'}
478+ if successful:
479+ data['result'] = True
480+ else:
481+ data['err'] = True
482+ return json.dumps(data) if encoded else data
483+
484+
485 class WSSTestMixin(object):
486 """Add some helper methods for testing secure WebSocket handlers."""
487
488
489=== added file 'server/guiserver/tests/test_auth.py'
490--- server/guiserver/tests/test_auth.py 1970-01-01 00:00:00 +0000
491+++ server/guiserver/tests/test_auth.py 2013-07-25 15:44:30 +0000
492@@ -0,0 +1,231 @@
493+# This file is part of the Juju GUI, which lets users view and manage Juju
494+# environments within a graphical interface (https://launchpad.net/juju-gui).
495+# Copyright (C) 2013 Canonical Ltd.
496+#
497+# This program is free software: you can redistribute it and/or modify it under
498+# the terms of the GNU Affero General Public License version 3, as published by
499+# the Free Software Foundation.
500+#
501+# This program is distributed in the hope that it will be useful, but WITHOUT
502+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
503+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
504+# Affero General Public License for more details.
505+#
506+# You should have received a copy of the GNU Affero General Public License
507+# along with this program. If not, see <http://www.gnu.org/licenses/>.
508+
509+"""Tests for the Juju GUI server authentication management."""
510+
511+import unittest
512+
513+from guiserver import auth
514+from guiserver.tests import helpers
515+
516+
517+class TestUser(unittest.TestCase):
518+
519+ def test_authenticated_repr(self):
520+ # An authenticated user is correctly represented.
521+ user = auth.User(
522+ username='the-doctor', password='bad-wolf', is_authenticated=True)
523+ expected = '<User: the-doctor (authenticated)>'
524+ self.assertEqual(expected, repr(user))
525+
526+ def test_not_authenticated_repr(self):
527+ # A not authenticated user is correctly represented.
528+ user = auth.User(
529+ username='the-doctor', password='bad-wolf', is_authenticated=False)
530+ expected = '<User: the-doctor (not authenticated)>'
531+ self.assertEqual(expected, repr(user))
532+
533+ def test_anonymous_repr(self):
534+ # An anonymous user is correctly represented.
535+ user = auth.User()
536+ expected = '<User: anonymous (not authenticated)>'
537+ self.assertEqual(expected, repr(user))
538+
539+
540+class AuthMiddlewareTestMixin(object):
541+ """Include tests for the AuthMiddleware.
542+
543+ Subclasses must subclass one of the API test mixins in helpers.
544+ """
545+
546+ def setUp(self):
547+ self.user = auth.User()
548+ self.auth = auth.AuthMiddleware(self.user, self.get_auth_backend())
549+
550+ def assert_user(self, username, password, is_authenticated):
551+ """Ensure the current user reflects the given values."""
552+ user = self.user
553+ self.assertEqual(username, user.username)
554+ self.assertEqual(password, user.password)
555+ self.assertEqual(is_authenticated, user.is_authenticated)
556+
557+ def test_login_request(self):
558+ # The authentication process starts if a login request is processed.
559+ request = self.make_login_request(username='user', password='passwd')
560+ self.auth.process_request(request)
561+ self.assertTrue(self.auth.in_progress())
562+ self.assert_user('user', 'passwd', False)
563+
564+ def test_login_success(self):
565+ # The user is logged in if the authentication process completes.
566+ request = self.make_login_request(username='user', password='passwd')
567+ self.auth.process_request(request)
568+ response = self.make_login_response()
569+ self.auth.process_response(response)
570+ self.assertFalse(self.auth.in_progress())
571+ self.assert_user('user', 'passwd', True)
572+
573+ def test_login_failure(self):
574+ # The user is not logged in if the authentication process fails.
575+ request = self.make_login_request()
576+ self.auth.process_request(request)
577+ response = self.make_login_response(successful=False)
578+ self.auth.process_response(response)
579+ self.assertFalse(self.auth.in_progress())
580+ self.assert_user('', '', False)
581+
582+ def test_request_mismatch(self):
583+ # The authentication fails if the request and response identifiers
584+ # don't match.
585+ request = self.make_login_request(
586+ request_id=42, username='user', password='passwd')
587+ self.auth.process_request(request)
588+ response = self.make_login_response(request_id=47)
589+ self.auth.process_response(response)
590+ self.assertTrue(self.auth.in_progress())
591+ self.assert_user('user', 'passwd', False)
592+
593+ def test_multiple_auth_requests(self):
594+ # Only the last authentication request is taken into consideration.
595+ request1 = self.make_login_request(request_id=1)
596+ request2 = self.make_login_request(
597+ request_id=2, username='user2', password='passwd2')
598+ self.auth.process_request(request1)
599+ self.auth.process_request(request2)
600+ # The first response arrives.
601+ response = self.make_login_response(request_id=1)
602+ self.auth.process_response(response)
603+ # The user is still not autheticated and the auth is in progress.
604+ self.assertFalse(self.user.is_authenticated)
605+ self.assertTrue(self.auth.in_progress())
606+ # The second response arrives.
607+ response = self.make_login_response(request_id=2)
608+ self.auth.process_response(response)
609+ # The user logged in and the auth process completed.
610+ self.assert_user('user2', 'passwd2', True)
611+ self.assertFalse(self.auth.in_progress())
612+
613+
614+class TestGoAuthMiddleware(
615+ helpers.GoAPITestMixin, AuthMiddlewareTestMixin, unittest.TestCase):
616+ pass
617+
618+
619+class TestPythonAuthMiddleware(
620+ helpers.PythonAPITestMixin, AuthMiddlewareTestMixin,
621+ unittest.TestCase):
622+ pass
623+
624+
625+class BackendTestMixin(object):
626+ """Include tests for the authentication backends.
627+
628+ Subclasses must subclass one of the API test mixins in helpers.
629+ """
630+
631+ def setUp(self):
632+ self.backend = self.get_auth_backend()
633+
634+ def test_get_request_id(self):
635+ # The request id is correctly returned.
636+ request = self.make_login_request(request_id=42)
637+ self.assertEqual(42, self.backend.get_request_id(request))
638+
639+ def test_get_request_id_failure(self):
640+ # If the request id cannot be found, None is returned.
641+ self.assertIsNone(self.backend.get_request_id({}))
642+
643+ def test_request_is_login(self):
644+ # True is returned if a login request is passed.
645+ request = self.make_login_request()
646+ self.assertTrue(self.backend.request_is_login(request))
647+
648+ def test_get_credentials(self):
649+ # The user name and password are returned parsing the login request.
650+ request = self.make_login_request(username='user', password='passwd')
651+ username, password = self.backend.get_credentials(request)
652+ self.assertEqual('user', username)
653+ self.assertEqual('passwd', password)
654+
655+ def test_login_succeeded(self):
656+ # True is returned if the login attempt succeeded.
657+ response = self.make_login_response()
658+ self.assertTrue(self.backend.login_succeeded(response))
659+
660+ def test_login_failed(self):
661+ # False is returned if the login attempt failed.
662+ response = self.make_login_response(successful=False)
663+ self.assertFalse(self.backend.login_succeeded(response))
664+
665+
666+class TestGoBackend(
667+ helpers.GoAPITestMixin, BackendTestMixin, unittest.TestCase):
668+
669+ def test_request_is_not_login(self):
670+ # False is returned if the passed data is not a login request.
671+ requests = (
672+ {},
673+ {
674+ 'RequestId': 1,
675+ 'Type': 'INVALID',
676+ 'Request': 'Login',
677+ 'Params': {'AuthTag': 'user', 'Password': 'passwd'},
678+ },
679+ {
680+ 'RequestId': 2,
681+ 'Type': 'Admin',
682+ 'Request': 'INVALID',
683+ 'Params': {'AuthTag': 'user', 'Password': 'passwd'},
684+ },
685+ {
686+ 'RequestId': 3,
687+ 'Type': 'Admin',
688+ 'Request': 'Login',
689+ 'Params': {'Password': 'passwd'},
690+ },
691+ )
692+ for request in requests:
693+ is_login = self.backend.request_is_login(request)
694+ self.assertFalse(is_login, request)
695+
696+
697+class TestPythonBackend(
698+ helpers.PythonAPITestMixin, BackendTestMixin, unittest.TestCase):
699+
700+ def test_request_is_not_login(self):
701+ # False is returned if the passed data is not a login request.
702+ requests = (
703+ {},
704+ {
705+ 'request_id': 42,
706+ 'op': 'INVALID',
707+ 'user': 'user',
708+ 'password': 'passwd',
709+ },
710+ {
711+ 'request_id': 42,
712+ 'op': 'login',
713+ 'password': 'passwd',
714+ },
715+ {
716+ 'request_id': 42,
717+ 'op': 'login',
718+ 'user': 'user',
719+ },
720+ )
721+ for request in requests:
722+ is_login = self.backend.request_is_login(request)
723+ self.assertFalse(is_login, request)
724
725=== modified file 'server/guiserver/tests/test_handlers.py'
726--- server/guiserver/tests/test_handlers.py 2013-07-23 08:21:12 +0000
727+++ server/guiserver/tests/test_handlers.py 2013-07-25 15:44:30 +0000
728@@ -16,6 +16,7 @@
729
730 """Tests for the Juju GUI server handlers."""
731
732+import json
733 import os
734 import shutil
735 import tempfile
736@@ -34,20 +35,25 @@
737 )
738
739 from guiserver import (
740+ auth,
741 clients,
742 handlers,
743+ manage,
744 )
745 from guiserver.tests import helpers
746
747
748-class TestWebSocketHandler(
749- AsyncHTTPSTestCase, LogTrapTestCase, helpers.WSSTestMixin):
750+class WebSocketHandlerTestMixin(object):
751+ """Base set up for all the WebSocketHandler test cases."""
752+
753+ hello_message = json.dumps({'hello': 'world'})
754
755 def get_app(self):
756- # In this test case a WebSocket server is created. The server creates a
757- # new client on each request. This client should forward messages to a
758- # WebSocket echo server. In order to test the communication, some of
759- # the tests create another client that connects to the server, e.g.:
760+ # In test cases including this mixin a WebSocket server is created.
761+ # The server creates a new client on each request. This client should
762+ # forward messages to a WebSocket echo server. In order to test the
763+ # communication, some of the tests create another client that connects
764+ # to the server, e.g.:
765 # ws-client -> ws-server -> ws-forwarding-client -> ws-echo-server
766 # Messages arriving to the echo server are returned back to the client:
767 # ws-echo-server -> ws-forwarding-client -> ws-server -> ws-client
768@@ -58,13 +64,13 @@
769 'io_loop': self.io_loop,
770 }
771 ws_options = {
772- 'jujuapi': self.echo_server_address,
773+ 'apiurl': self.echo_server_address,
774 'io_loop': self.io_loop,
775 }
776 return web.Application([
777 (r'/echo', helpers.EchoWebSocketHandler, echo_options),
778 (r'/ws', handlers.WebSocketHandler, ws_options),
779- ])
780+ ], auth_backend=auth.get_backend(manage.DEFAULT_API_VERSION))
781
782 def make_client(self):
783 """Return a WebSocket client ready to be connected to the server."""
784@@ -73,12 +79,29 @@
785 callback = lambda message: None
786 return clients.websocket_connect(self.io_loop, url, callback)
787
788- def make_handler(self, headers=None):
789+ def make_handler(self, headers=None, mock_protocol=False):
790 """Create and return a WebSocketHandler instance."""
791 if headers is None:
792 headers = {}
793 request = mock.Mock(headers=headers)
794- return handlers.WebSocketHandler(self.get_app(), request)
795+ handler = handlers.WebSocketHandler(self.get_app(), request)
796+ if mock_protocol:
797+ # Mock the underlying connection protocol.
798+ handler.ws_connection = mock.Mock()
799+ return handler
800+
801+
802+class TestWebSocketHandlerConnection(
803+ WebSocketHandlerTestMixin, helpers.WSSTestMixin, LogTrapTestCase,
804+ AsyncHTTPSTestCase):
805+
806+ def mock_websocket_connect(self):
807+ """Mock the guiserver.clients.websocket_connect function."""
808+ future = concurrent.Future()
809+ future.set_result(mock.Mock())
810+ mock_websocket_connect = mock.Mock(return_value=future)
811+ return mock.patch(
812+ 'guiserver.handlers.websocket_connect', mock_websocket_connect)
813
814 @gen_test
815 def test_initialization(self):
816@@ -124,30 +147,55 @@
817 self.assertIn('Origin', headers)
818 self.assertEqual(self.get_url('/echo'), headers['Origin'])
819
820- @mock.patch('guiserver.handlers.websocket_connect')
821- def test_client_callback(self, mock_websocket_connect):
822+ def test_client_callback(self):
823 # The WebSocket client is created passing the proper callback.
824 handler = self.make_handler()
825- handler.initialize(self.echo_server_address, self.io_loop)
826+ with self.mock_websocket_connect() as mock_websocket_connect:
827+ handler.initialize(self.echo_server_address, self.io_loop)
828 self.assertEqual(1, mock_websocket_connect.call_count)
829 self.assertIn(
830 handler.on_juju_message, mock_websocket_connect.call_args[0])
831
832+ @gen_test
833+ def test_connection_closed_by_client(self):
834+ # The proxy connection is terminated when the client disconnects.
835+ client = yield self.make_client()
836+ yield client.close()
837+ yield self.echo_server_closed_future
838+
839+ @gen_test
840+ def test_connection_closed_by_server(self):
841+ # The proxy connection is terminated when the server disconnects.
842+ client = yield self.make_client()
843+ # A server disconnection is logged as an error.
844+ expected_log = '.*Juju API unexpectedly disconnected'
845+ with ExpectLog('', expected_log, required=True):
846+ # Fire the Future in order to force an echo server disconnection.
847+ self.echo_server_closed_future.set_result(None)
848+ message = yield client.read_message()
849+ self.assertIsNone(message)
850+
851+
852+class TestWebSocketHandlerProxy(
853+ WebSocketHandlerTestMixin, helpers.WSSTestMixin, LogTrapTestCase,
854+ AsyncHTTPSTestCase):
855+
856 @mock.patch('guiserver.clients.WebSocketClientConnection')
857 def test_from_browser_to_juju(self, mock_juju_connection):
858 # A message from the browser is forwarded to the remote server.
859 handler = self.make_handler()
860 yield handler.initialize(self.echo_server_address, self.io_loop)
861- handler.on_message('hello')
862- mock_juju_connection.write_message.assert_called_once_with('hello')
863+ handler.on_message(self.hello_message)
864+ mock_juju_connection.write_message.assert_called_once_with(
865+ self.hello_message)
866
867 def test_from_juju_to_browser(self):
868 # A message from the remote server is returned to the browser.
869 handler = self.make_handler()
870 handler.initialize(self.echo_server_address, self.io_loop)
871 with mock.patch('guiserver.handlers.WebSocketHandler.write_message'):
872- handler.on_juju_message('hello')
873- handler.write_message.assert_called_once_with('hello')
874+ handler.on_juju_message(self.hello_message)
875+ handler.write_message.assert_called_once_with(self.hello_message)
876
877 @gen_test
878 def test_queued_messages(self):
879@@ -158,38 +206,90 @@
880 with mock.patch(mock_path) as mock_write_message:
881 initialization = handler.initialize(
882 self.echo_server_address, self.io_loop)
883- handler.on_message('hello')
884+ handler.on_message(self.hello_message)
885 self.assertFalse(mock_write_message.called)
886 yield initialization
887- mock_write_message.assert_called_once_with('hello')
888+ mock_write_message.assert_called_once_with(self.hello_message)
889
890 @gen_test
891 def test_end_to_end_proxy(self):
892 # Messages are correctly forwarded from the client to the echo server
893 # and back to the client.
894 client = yield self.make_client()
895- client.write_message('boomerang')
896+ client.write_message(self.hello_message)
897 message = yield client.read_message()
898- self.assertEqual('boomerang', message)
899-
900- @gen_test
901- def test_connection_closed_by_client(self):
902- # The proxy connection is terminated when the client disconnects.
903- client = yield self.make_client()
904- yield client.close()
905- yield self.echo_server_closed_future
906-
907- @gen_test
908- def test_connection_closed_by_server(self):
909- # The proxy connection is terminated when the server disconnects.
910- client = yield self.make_client()
911- # A server disconnection is logged as an error.
912- expected_log = '.*Juju API unexpectedly disconnected'
913- with ExpectLog('', expected_log, required=True):
914- # Fire the Future in order to force an echo server disconnection.
915- self.echo_server_closed_future.set_result(None)
916- message = yield client.read_message()
917- self.assertIsNone(message)
918+ self.assertEqual(self.hello_message, message)
919+
920+ @gen_test
921+ def test_invalid_json(self):
922+ # A warning is logged if the message is not valid JSON.
923+ client = yield self.make_client()
924+ expected_log = 'JSON decoder: message is not valid JSON: not-json'
925+ with ExpectLog('', expected_log, required=True):
926+ client.write_message('not-json')
927+ yield client.read_message()
928+
929+ @gen_test
930+ def test_not_a_dict(self):
931+ # A warning is logged if the decoded message is not a dict.
932+ client = yield self.make_client()
933+ expected_log = 'JSON decoder: message is not a dict: "not-a-dict"'
934+ with ExpectLog('', expected_log, required=True):
935+ client.write_message('"not-a-dict"')
936+ yield client.read_message()
937+
938+
939+class TestWebSocketHandlerAuthentication(
940+ WebSocketHandlerTestMixin, helpers.WSSTestMixin,
941+ helpers.GoAPITestMixin, LogTrapTestCase, AsyncHTTPSTestCase):
942+
943+ def setUp(self):
944+ super(TestWebSocketHandlerAuthentication, self).setUp()
945+ self.handler = self.make_handler(mock_protocol=True)
946+ self.handler.initialize(self.echo_server_address, self.io_loop)
947+
948+ def send_login_request(self):
949+ """Create a login request and send it to the handler."""
950+ request = self.make_login_request(encoded=True)
951+ self.handler.on_message(request)
952+
953+ def send_login_response(self, successful):
954+ """Create a login response and send it to the handler."""
955+ response = self.make_login_response(
956+ successful=successful, encoded=True)
957+ self.handler.on_juju_message(response)
958+
959+ def test_authentication_success(self):
960+ # The authentication process completes and the user is logged in.
961+ self.assertFalse(self.handler.user.is_authenticated)
962+ self.send_login_request()
963+ self.assertFalse(self.handler.user.is_authenticated)
964+ self.assertTrue(self.handler.auth.in_progress())
965+ self.send_login_response(True)
966+ self.assertTrue(self.handler.user.is_authenticated)
967+ self.assertFalse(self.handler.auth.in_progress())
968+
969+ def test_authentication_failure(self):
970+ # The user is not logged in if the authentication fails.
971+ self.send_login_request()
972+ self.send_login_response(False)
973+ self.assertFalse(self.handler.user.is_authenticated)
974+ self.assertFalse(self.handler.auth.in_progress())
975+
976+ def test_already_logged_in(self):
977+ # Authentication is no longer attempted if the user already logged in.
978+ self.send_login_request()
979+ self.send_login_response(True)
980+ self.send_login_request()
981+ self.assertTrue(self.handler.user.is_authenticated)
982+ self.assertFalse(self.handler.auth.in_progress())
983+
984+ def test_not_in_progress(self):
985+ # Authentication responses are not processed if the authentication is
986+ # not in progress.
987+ self.send_login_response(True)
988+ self.assertFalse(self.handler.user.is_authenticated)
989+ self.assertFalse(self.handler.auth.in_progress())
990
991
992 class TestIndexHandler(AsyncHTTPTestCase, LogTrapTestCase):
993
994=== modified file 'server/guiserver/tests/test_manage.py'
995--- server/guiserver/tests/test_manage.py 2013-07-18 15:56:09 +0000
996+++ server/guiserver/tests/test_manage.py 2013-07-25 15:44:30 +0000
997@@ -41,18 +41,23 @@
998 mock_options.define.assert_called_once_with('debug', default=False)
999
1000
1001-class TestValidateRequired(unittest.TestCase):
1002+class ValidatorTestMixin(object):
1003+ """Add methods for testing functions producing a system exit."""
1004
1005 @contextmanager
1006- def assert_sysexit(self, option):
1007+ def assert_sysexit(self, error):
1008 """Ensure the code in the context manager block produces a system exit.
1009
1010- Also check that the given error refers to the given option.
1011+ Also check that the given error is returned.
1012 """
1013- expected_error = 'error: the {} argument is required'.format(option)
1014 with mock.patch('sys.exit') as mock_exit:
1015 yield
1016- mock_exit.assert_called_once_with(expected_error)
1017+ mock_exit.assert_called_once_with(error)
1018+
1019+
1020+class TestValidateRequired(ValidatorTestMixin, unittest.TestCase):
1021+
1022+ error = 'error: the {} argument is required'
1023
1024 def test_success(self):
1025 # The validation passes if the args are correctly found.
1026@@ -66,26 +71,49 @@
1027
1028 def test_failure(self):
1029 with mock.patch('guiserver.manage.options', {'arg1': ''}):
1030- with self.assert_sysexit('arg1'):
1031+ with self.assert_sysexit(self.error.format('arg1')):
1032 manage._validate_required('arg1')
1033
1034 def test_failure_multiple_args(self):
1035 options = {'arg1': 'value1', 'arg2': ''}
1036 with mock.patch('guiserver.manage.options', options):
1037- with self.assert_sysexit('arg2'):
1038+ with self.assert_sysexit(self.error.format('arg2')):
1039 manage._validate_required(*options.keys())
1040
1041 def test_failure_missing(self):
1042 with mock.patch('guiserver.manage.options', {'arg1': None}):
1043- with self.assert_sysexit('arg1'):
1044+ with self.assert_sysexit(self.error.format('arg1')):
1045 manage._validate_required('arg1')
1046
1047 def test_failure_empty(self):
1048 with mock.patch('guiserver.manage.options', {'arg1': ' '}):
1049- with self.assert_sysexit('arg1'):
1050+ with self.assert_sysexit(self.error.format('arg1')):
1051 manage._validate_required('arg1')
1052
1053 def test_failure_invalid_type(self):
1054 with mock.patch('guiserver.manage.options', {'arg1': 42}):
1055- with self.assert_sysexit('arg1'):
1056+ with self.assert_sysexit(self.error.format('arg1')):
1057 manage._validate_required('arg1')
1058+
1059+
1060+class TestValidateChoices(ValidatorTestMixin, unittest.TestCase):
1061+
1062+ choices = ('choice1', 'choice2')
1063+ error = 'error: accepted values for the {} argument are: choice1, choice2'
1064+
1065+ def test_success(self):
1066+ # The validation passes if the value is included in the choices.
1067+ with mock.patch('guiserver.manage.options', {'arg1': 'choice1'}):
1068+ manage._validate_choices('arg1', self.choices)
1069+
1070+ def test_failure_invalid_choice(self):
1071+ # The validation fails if the value is not in choices.
1072+ with mock.patch('guiserver.manage.options', {'arg1': 'not-a-choice'}):
1073+ with self.assert_sysexit(self.error.format('arg1')):
1074+ manage._validate_choices('arg1', self.choices)
1075+
1076+ def test_failure_missing(self):
1077+ # The validation fails if the value is missing.
1078+ with mock.patch('guiserver.manage.options', {'arg1': None}):
1079+ with self.assert_sysexit(self.error.format('arg1')):
1080+ manage._validate_choices('arg1', self.choices)
1081
1082=== modified file 'server/guiserver/tests/test_utils.py'
1083--- server/guiserver/tests/test_utils.py 2013-07-22 14:16:47 +0000
1084+++ server/guiserver/tests/test_utils.py 2013-07-25 15:44:30 +0000
1085@@ -16,9 +16,11 @@
1086
1087 """Tests for the Juju GUI server utilities."""
1088
1089+import json
1090 import unittest
1091
1092 import mock
1093+from tornado.testing import ExpectLog
1094
1095 from guiserver import utils
1096
1097@@ -39,6 +41,29 @@
1098 self.assertEqual({'Origin': 'https://server.example.com'}, headers)
1099
1100
1101+class TestJsonDecodeDict(unittest.TestCase):
1102+
1103+ def test_valid(self):
1104+ # A valid JSON is decoded without errors.
1105+ data = {'key1': 'value1', 'key2': 'value2'}
1106+ message = json.dumps(data)
1107+ self.assertEqual(data, utils.json_decode_dict(message))
1108+
1109+ def test_invalid_json(self):
1110+ # If the message is not a valid JSON string, a warning is logged and
1111+ # None is returned.
1112+ expected_log = 'JSON decoder: message is not valid JSON: not-json'
1113+ with ExpectLog('', expected_log, required=True):
1114+ self.assertIsNone(utils.json_decode_dict('not-json'))
1115+
1116+ def test_invalid_type(self):
1117+ # If the resulting object is not a dict-like object, a warning is
1118+ # logged and None is returned.
1119+ expected_log = 'JSON decoder: message is not a dict: "not-a-dict"'
1120+ with ExpectLog('', expected_log, required=True):
1121+ self.assertIsNone(utils.json_decode_dict('"not-a-dict"'))
1122+
1123+
1124 class TestRequestSummary(unittest.TestCase):
1125
1126 def test_summary(self):
1127
1128=== modified file 'server/guiserver/utils.py'
1129--- server/guiserver/utils.py 2013-07-22 14:01:58 +0000
1130+++ server/guiserver/utils.py 2013-07-25 15:44:30 +0000
1131@@ -16,8 +16,12 @@
1132
1133 """Juju GUI server utility functions and classes."""
1134
1135+import collections
1136+import logging
1137 import urlparse
1138
1139+from tornado import escape
1140+
1141
1142 def get_headers(request, websocket_url):
1143 """Return additional headers to be included in the client connection.
1144@@ -32,6 +36,25 @@
1145 return {'Origin': origin}
1146
1147
1148+def json_decode_dict(message):
1149+ """Decode the given JSON message, returning a Python dict.
1150+
1151+ If the message is not a valid JSON string, or if the resulting object is
1152+ not a dict-like object, log a warning and return None.
1153+ """
1154+ try:
1155+ data = escape.json_decode(message)
1156+ except ValueError:
1157+ msg = 'JSON decoder: message is not valid JSON: {}'.format(message)
1158+ logging.warning(msg)
1159+ return None
1160+ if not isinstance(data, collections.Mapping):
1161+ msg = 'JSON decoder: message is not a dict: {}'.format(message)
1162+ logging.warning(msg)
1163+ return None
1164+ return data
1165+
1166+
1167 def request_summary(request):
1168 """Return a string representing a summary for the given request."""
1169 return '{} {} ({})'.format(request.method, request.uri, request.remote_ip)
1170
1171=== modified file 'server/runserver.py'
1172--- server/runserver.py 2013-07-18 13:59:20 +0000
1173+++ server/runserver.py 2013-07-25 15:44:30 +0000
1174@@ -18,7 +18,8 @@
1175
1176 Arguments example:
1177 --guiroot="/var/lib/juju/agents/unit-juju-gui-0/charm/juju-gui/build-prod"
1178- --jujuapi="wss://ec2-75-101-177-185.compute-1.example.com:17070"
1179+ --apiurl="wss://ec2-75-101-177-185.compute-1.example.com:17070"
1180+ --apiversion="go"
1181 """
1182
1183 from guiserver import manage

Subscribers

People subscribed via source and target branches