Merge lp:~frankban/charms/precise/juju-gui/server-auth into lp:~juju-gui/charms/precise/juju-gui/trunk
- Precise Pangolin (12.04)
- server-auth
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
charmers | Pending | ||
Review via email: mp+176738@code.launchpad.net |
Commit message
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`
Francesco Banconi (frankban) wrote : | # |
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:/
File server/
https:/
server/
Abstract.
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/
- 86. By Francesco Banconi
-
Changes as per review.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File server/
https:/
server/
mock.patch(
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:/
File server/
https:/
server/
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.
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:/
File server/
https:/
server/
by juju status.')
Double quotes around "juju status", maybe?
- 87. By Francesco Banconi
-
Changes as per review.
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:/
> server/
returned by juju
> status.')
> Double quotes around "juju status", maybe?
Done.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
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:/
Preview Diff
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 |
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: guiserver/ apps.py guiserver/ auth.py guiserver/ handlers. py guiserver/ manage. py guiserver/ tests/helpers. py guiserver/ tests/test_ auth.py guiserver/ tests/test_ handlers. py guiserver/ tests/test_ manage. py guiserver/ tests/test_ utils.py guiserver/ utils.py
A [revision details]
M revision
M server/
A server/
M server/
M server/
M server/
A server/
M server/
M server/
M server/
M server/
M server/runserver.py