Merge lp:~michael.nelson/canonical-identity-provider/demo-test into lp:canonical-identity-provider/release

Proposed by Michael Nelson
Status: Rejected
Rejected by: Daniel Manrique
Proposed branch: lp:~michael.nelson/canonical-identity-provider/demo-test
Merge into: lp:canonical-identity-provider/release
Diff against target: 300 lines (+230/-3)
4 files modified
src/webui/forms.py (+72/-0)
src/webui/templates/account/ssh_keys.html (+23/-2)
src/webui/tests/test_forms.py (+113/-0)
src/webui/views/account.py (+22/-1)
To merge this branch: bzr merge lp:~michael.nelson/canonical-identity-provider/demo-test
Reviewer Review Type Date Requested Status
Ubuntu One hackers Pending
Review via email: mp+296275@code.launchpad.net
To post a comment you must log in.

Unmerged revisions

1476. By Michael Nelson

Demo test

1475. By Michael Nelson

Thomi's changes.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/webui/forms.py'
2--- src/webui/forms.py 2016-05-30 21:15:35 +0000
3+++ src/webui/forms.py 2016-06-02 01:29:45 +0000
4@@ -2,6 +2,8 @@
5 # the GNU Affero General Public License version 3 (see the file LICENSE).
6
7 from django import forms
8+from django.utils.html import escape
9+from django.utils.safestring import mark_safe
10 from django.utils.translation import ugettext_lazy as _
11 from gpgservice_client import (
12 GPGServiceException,
13@@ -13,6 +15,8 @@
14 from webservices.launchpad import (
15 LaunchpadAPIError,
16 add_lp_ssh_key,
17+ delete_lp_ssh_key,
18+ get_lp_ssh_keys,
19 )
20 from webui.gpg import (
21 SSOGPGClient,
22@@ -252,3 +256,71 @@
23 def add_key_to_launchpad(self):
24 keytext = self.cleaned_data['ssh_key']
25 add_lp_ssh_key(self.account.openid_identifier, keytext, dry_run=False)
26+
27+
28+def format_ssh_key(ssh_key):
29+ """Decide how to display 'ssh_key'.
30+
31+ SSH keys are freeform text, so users absolutely *will* try and add keys
32+ that contain javascript, so we need to make sure they're escaped when
33+ being displayed to the user.
34+ """
35+ try:
36+ key_type, key_text, comment = ssh_key.split(' ', 2)
37+ except ValueError:
38+ # This should never happen, as launchpad is supposed to guarantee a
39+ # valid ssh key format. However, we need to exercise this code path
40+ # in tests to ensure that users malicously POST'ing bad key formats
41+ # will get a ValidationError.
42+ # Just return the escaped data - this will never be run in production:
43+ return mark_safe_lazy(
44+ '<label>Bad Key Data: <span>{key}</span></label>').format(
45+ key=escape(ssh_key))
46+ else:
47+ #return mark_safe_lazy(
48+ # format_html isn't being used here because it's not lazy?
49+ return mark_safe(
50+ '<label>{key_type_label}<span>{key_type}</span></label> '
51+ '<label>{key_text_label}<span>{key_text}</span></label> '
52+ '<label>{comment_label}<span>{comment}</span></label> '.format(
53+ key_type_label=_("Key Type: "),
54+ key_type=escape(key_type),
55+ key_text_label=_("Key Text: "),
56+ key_text=escape(key_text),
57+ comment_label=_("Comment: "),
58+ comment=escape(comment)
59+ ))
60+
61+
62+from django.forms.widgets import CheckboxChoiceInput
63+
64+class SSHChoiceInput(CheckboxChoiceInput):
65+
66+ def __init__(self, *args, **kwargs):
67+ super(SSHChoiceInput, self).__init__(*args, **kwargs)
68+ self.choice_label = format_ssh_key(self.choice_label)
69+
70+
71+class SSHFieldRenderer(forms.widgets.CheckboxFieldRenderer):
72+
73+ choice_input_class = SSHChoiceInput
74+
75+
76+class DeleteSSHKeyForm(forms.Form):
77+
78+ ssh_keys = forms.MultipleChoiceField(
79+ required=True,
80+ widget=forms.CheckboxSelectMultiple(renderer=SSHFieldRenderer),
81+ )
82+
83+ def __init__(self, account, *args, **kwargs):
84+ super(DeleteSSHKeyForm, self).__init__(*args, **kwargs)
85+ self.account = account
86+ keys = get_lp_ssh_keys(self.account.openid_identifier)
87+ choices = [(key, key) for key in keys]
88+ self.fields['ssh_keys'].choices = choices
89+
90+ def delete_keys_from_launchpad(self):
91+ for key in self.cleaned_data['ssh_keys']:
92+ delete_lp_ssh_key(self.account.openid_identifier, key,
93+ dry_run=False)
94
95=== modified file 'src/webui/templates/account/ssh_keys.html'
96--- src/webui/templates/account/ssh_keys.html 2016-05-26 00:06:10 +0000
97+++ src/webui/templates/account/ssh_keys.html 2016-06-02 01:29:45 +0000
98@@ -8,9 +8,30 @@
99 {% block title %}{% trans "SSH Keys" %}{% endblock %}
100
101 {% block text_title %}
102- <h1 class="u1-h-main">{% trans "SSH Keys" %}</h1>
103+ <h1 class="u1-h-main">{% trans "Change your SSH keys" %}</h1>
104 {% endblock %}
105
106 {% block content %}
107-<p>Coming soon!</p>
108+{% if delete_key_form.fields.ssh_keys.choices %}
109+ <b>Delete SSH Keys:</b>
110+ <form action="" method="POST">
111+ {% csrf_token %}
112+ {{ delete_key_form.non_field_errors }}
113+ {{ delete_key_form.fields.ssh_keys.errors }}
114+ <ul>
115+ {% for key_choice_id, key_choice_label in delete_key_form.fields.ssh_keys.choices %}
116+ <li>{{key_choice_id}}</li>
117+ {% endfor %}
118+ </ul>
119+ {{ delete_key_form }}
120+ <input class="cta" type="submit" name="delete_key" value="{% trans "Delete Selected Keys" %}"/>
121+ </form>
122+{% endif %}
123+
124+<b>Import new SSH key</b>
125+<form action="" method="POST">
126+ {% csrf_token %}
127+ {{ import_key_form }}
128+ <input class="cta" type="submit" name="import_key" value="{% trans "Import SSH key" %}"/>
129+</form>
130 {% endblock %}
131
132=== modified file 'src/webui/tests/test_forms.py'
133--- src/webui/tests/test_forms.py 2016-05-30 21:15:35 +0000
134+++ src/webui/tests/test_forms.py 2016-06-02 01:29:45 +0000
135@@ -20,10 +20,12 @@
136 from webui.forms import (
137 ClaimOpenPGPKeyForm,
138 ClaimSSHKeyForm,
139+ DeleteSSHKeyForm,
140 DisabledGPGKeysForm,
141 EnabledGPGKeysForm,
142 PendingGPGValidationTokenForm,
143 VerifySignedTextForm,
144+ format_ssh_key,
145 )
146 from webui.tests.gpg_fixture import SSOGPGServiceFixture
147 from webui.views import gpg_messages
148@@ -672,3 +674,114 @@
149 account.openid_identifier,
150 'ssh-rsa foo bar',
151 dry_run=False)
152+
153+
154+class FormatSSHKeyTestCase(SSOBaseTestCase):
155+
156+ def test_escapes_xss(self):
157+ bad_content = '<script>window.alert("boo!");</script>'
158+ bad_key = ' '.join((bad_content,) * 3)
159+ formatted_key = format_ssh_key(bad_key)
160+ self.assertNotIn(bad_content, formatted_key)
161+ self.assertIn(
162+ '&lt;script&gt;window.alert(&quot;boo!&quot;);&lt;/script&gt;',
163+ formatted_key
164+ )
165+
166+
167+class DeleteSSHKeyFormTestCase(SSOBaseTestCase):
168+
169+ SAMPLE_RSA_KEY = 'ssh-rsa keytext_goes_here comment goes here'
170+ SAMPLE_DSA_KEY = 'ssh-dss more_keytext_here this is a comment'
171+
172+ def setUp(self):
173+ super(DeleteSSHKeyFormTestCase, self).setUp()
174+ self.account = self.factory.make_account()
175+
176+ def patch_lp_delete_key(self, raise_exception=None):
177+ """Patch the 'delete_lp_ssh_key', creating realistic side effects.
178+
179+ :param raise_exception: If specified, must be one of NoSuchAccount,
180+ SSHKeyAdditionTypeError or SSHKeyAdditionDataError Exceptions will
181+ be raised with the same message as if they'd been returned from
182+ launchpad.
183+ """
184+ exception_messages = {
185+ NoSuchAccount: "No account found for openid identifier '{openid}'",
186+ SSHKeyAdditionTypeError: "Invalid SSH key type: '{sshkeytype}'",
187+ SSHKeyAdditionDataError: "Invalid SSH key data: '{sshkey}'",
188+ }
189+
190+ def _side_effect(openid_id, key_text, dry_run):
191+ key_type = key_text.split(' ')[0]
192+ message = exception_messages[raise_exception].format(
193+ openid=openid_id,
194+ sshkeytype=key_type,
195+ sshkey=key_text,
196+ )
197+ raise raise_exception(400, message)
198+
199+ mock = self.patch('webui.forms.delete_lp_ssh_key')
200+ if raise_exception is not None:
201+ mock.side_effect = _side_effect
202+ return mock
203+
204+ def patch_get_keys(self, returned_keys=[]):
205+ mock = self.patch('webui.forms.get_lp_ssh_keys')
206+ mock.return_value = returned_keys
207+ return mock
208+
209+ def assert_form_field_has_error(self, form, field, expected_error):
210+ self.assertIn(field, form.errors)
211+ self.assertEqual([expected_error], form.errors[field])
212+
213+ def test_form_widget_with_labels(self):
214+ self.patch_get_keys([self.SAMPLE_RSA_KEY, self.SAMPLE_DSA_KEY])
215+ form = DeleteSSHKeyForm(self.account)
216+
217+
218+ html = form.as_ul()
219+
220+ self.assertIn('<label>Key Type', html)
221+
222+ def test_form_passes_account_id_to_get_lp_keys(self):
223+ mock_get_keys = self.patch_get_keys()
224+ DeleteSSHKeyForm(self.account)
225+
226+ mock_get_keys.assert_called_once_with(self.account.openid_identifier)
227+
228+ def test_form_with_no_keys(self):
229+ self.patch_get_keys()
230+ form = DeleteSSHKeyForm(self.account)
231+
232+ self.assertEqual(0, len(form.fields['ssh_keys'].choices))
233+
234+ def test_form_multiple_keys(self):
235+ self.patch_get_keys([self.SAMPLE_RSA_KEY, self.SAMPLE_DSA_KEY])
236+ form = DeleteSSHKeyForm(self.account)
237+
238+ self.assertEqual(2, len(form.fields['ssh_keys'].choices))
239+ rsa_choice, dsa_choice = form.fields['ssh_keys'].choices
240+ self.assertEqual(
241+ (self.SAMPLE_RSA_KEY, format_ssh_key(self.SAMPLE_RSA_KEY)),
242+ rsa_choice,
243+ )
244+ self.assertEqual(
245+ (self.SAMPLE_DSA_KEY, format_ssh_key(self.SAMPLE_DSA_KEY)),
246+ dsa_choice,
247+ )
248+
249+ def test_form_deletes_ssh_key(self):
250+ self.patch_get_keys([self.SAMPLE_RSA_KEY])
251+ form = DeleteSSHKeyForm(
252+ self.account, {'ssh_keys': [self.SAMPLE_RSA_KEY]})
253+
254+ mock_lp_delete = self.patch_lp_delete_key()
255+ self.assertTrue(form.is_valid())
256+ mock_lp_delete.reset_mock()
257+ form.delete_keys_from_launchpad()
258+
259+ mock_lp_delete.assert_called_once_with(
260+ self.account.openid_identifier,
261+ self.SAMPLE_RSA_KEY,
262+ dry_run=False)
263
264=== modified file 'src/webui/views/account.py'
265--- src/webui/views/account.py 2016-05-26 00:06:10 +0000
266+++ src/webui/views/account.py 2016-06-02 01:29:45 +0000
267@@ -64,6 +64,8 @@
268 from webui.decorators import check_readonly, sso_login_required
269 from webui.forms import (
270 ClaimOpenPGPKeyForm,
271+ ClaimSSHKeyForm,
272+ DeleteSSHKeyForm,
273 DisabledGPGKeysForm,
274 EnabledGPGKeysForm,
275 PendingGPGValidationTokenForm,
276@@ -636,4 +638,23 @@
277 @sso_login_required
278 @check_readonly
279 def ssh_keys(request):
280- return render(request, 'account/ssh_keys.html', {})
281+
282+ if 'delete_keys' in request.POST:
283+ delete_keys_form = DeleteSSHKeyForm(request.user, request.POST)
284+ if delete_keys_form.is_valid():
285+ delete_keys_form.delete_keys_from_launchpad()
286+ else:
287+ delete_keys_form = DeleteSSHKeyForm(request.user)
288+
289+ if 'import_key' in request.POST:
290+ import_key_form = ClaimSSHKeyForm(request.user, request.POST)
291+ if import_key_form.is_valid():
292+ import_key_form.add_key_to_launchpad()
293+ else:
294+ import_key_form = ClaimSSHKeyForm(request.user)
295+
296+ context = {
297+ 'delete_key_form': delete_keys_form,
298+ 'import_key_form': import_key_form,
299+ }
300+ return render(request, 'account/ssh_keys.html', context)