Merge ~cjwatson/launchpad:access-token-ui into launchpad:master
- Git
- lp:~cjwatson/launchpad
- access-token-ui
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | 81db109277f6ff9531e33157d7fc31f61e243939 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:access-token-ui |
Merge into: | launchpad:master |
Diff against target: |
1099 lines (+957/-11) 12 files modified
lib/lp/services/auth/browser.py (+80/-0) lib/lp/services/auth/configure.zcml (+7/-0) lib/lp/services/auth/interfaces.py (+29/-10) lib/lp/services/auth/javascript/tests/test_tokens.html (+121/-0) lib/lp/services/auth/javascript/tests/test_tokens.js (+181/-0) lib/lp/services/auth/javascript/tokens.js (+151/-0) lib/lp/services/auth/model.py (+10/-1) lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt (+122/-0) lib/lp/services/auth/tests/test_browser.py (+151/-0) lib/lp/services/auth/tests/test_model.py (+81/-0) lib/lp/services/auth/tests/test_yuitests.py (+23/-0) lib/lp/testing/factory.py (+1/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ioana Lasc (community) | Approve | ||
Review via email: mp+410560@code.launchpad.net |
Commit message
Add UI for personal access tokens
Description of the change
This requires JavaScript in order to ensure that the new token secret is never stored in the database.
To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/services/auth/browser.py b/lib/lp/services/auth/browser.py | |||
2 | 0 | new file mode 100644 | 0 | new file mode 100644 |
3 | index 0000000..5c9a35d | |||
4 | --- /dev/null | |||
5 | +++ b/lib/lp/services/auth/browser.py | |||
6 | @@ -0,0 +1,80 @@ | |||
7 | 1 | # Copyright 2021 Canonical Ltd. This software is licensed under the | ||
8 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
9 | 3 | |||
10 | 4 | """UI for personal access tokens.""" | ||
11 | 5 | |||
12 | 6 | __all__ = [ | ||
13 | 7 | "AccessTokensView", | ||
14 | 8 | ] | ||
15 | 9 | |||
16 | 10 | from lazr.restful.interface import ( | ||
17 | 11 | copy_field, | ||
18 | 12 | use_template, | ||
19 | 13 | ) | ||
20 | 14 | from zope.component import getUtility | ||
21 | 15 | from zope.interface import Interface | ||
22 | 16 | |||
23 | 17 | from lp import _ | ||
24 | 18 | from lp.app.browser.launchpadform import ( | ||
25 | 19 | action, | ||
26 | 20 | LaunchpadFormView, | ||
27 | 21 | ) | ||
28 | 22 | from lp.app.errors import UnexpectedFormData | ||
29 | 23 | from lp.app.widgets.date import DateTimeWidget | ||
30 | 24 | from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget | ||
31 | 25 | from lp.services.auth.interfaces import ( | ||
32 | 26 | IAccessToken, | ||
33 | 27 | IAccessTokenSet, | ||
34 | 28 | ) | ||
35 | 29 | from lp.services.propertycache import cachedproperty | ||
36 | 30 | from lp.services.webapp.publisher import canonical_url | ||
37 | 31 | |||
38 | 32 | |||
39 | 33 | class IAccessTokenCreateSchema(Interface): | ||
40 | 34 | """Schema for creating a personal access token.""" | ||
41 | 35 | |||
42 | 36 | use_template(IAccessToken, include=[ | ||
43 | 37 | "description", | ||
44 | 38 | "scopes", | ||
45 | 39 | ]) | ||
46 | 40 | |||
47 | 41 | date_expires = copy_field( | ||
48 | 42 | IAccessToken["date_expires"], | ||
49 | 43 | description=_("When the token should expire.")) | ||
50 | 44 | |||
51 | 45 | |||
52 | 46 | class AccessTokensView(LaunchpadFormView): | ||
53 | 47 | |||
54 | 48 | schema = IAccessTokenCreateSchema | ||
55 | 49 | custom_widget_scopes = LabeledMultiCheckBoxWidget | ||
56 | 50 | custom_widget_date_expires = DateTimeWidget | ||
57 | 51 | |||
58 | 52 | @property | ||
59 | 53 | def label(self): | ||
60 | 54 | return "Personal access tokens for %s" % self.context.display_name | ||
61 | 55 | |||
62 | 56 | page_title = "Personal access tokens" | ||
63 | 57 | |||
64 | 58 | @cachedproperty | ||
65 | 59 | def access_tokens(self): | ||
66 | 60 | return list(getUtility(IAccessTokenSet).findByTarget( | ||
67 | 61 | self.context, visible_by_user=self.user)) | ||
68 | 62 | |||
69 | 63 | @action("Revoke", name="revoke") | ||
70 | 64 | def revoke_action(self, action, data): | ||
71 | 65 | form = self.request.form | ||
72 | 66 | token_id = form.get("token_id") | ||
73 | 67 | if token_id is None: | ||
74 | 68 | raise UnexpectedFormData("Missing token_id") | ||
75 | 69 | try: | ||
76 | 70 | token_id = int(token_id) | ||
77 | 71 | except ValueError: | ||
78 | 72 | raise UnexpectedFormData("token_id is not an integer") | ||
79 | 73 | token = getUtility(IAccessTokenSet).getByTargetAndID( | ||
80 | 74 | self.context, token_id, visible_by_user=self.user) | ||
81 | 75 | if token is not None: | ||
82 | 76 | token.revoke(self.user) | ||
83 | 77 | self.request.response.addInfoNotification( | ||
84 | 78 | "Token revoked successfully.") | ||
85 | 79 | self.request.response.redirect( | ||
86 | 80 | canonical_url(self.context, view_name="+access-tokens")) | ||
87 | diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml | |||
88 | index f300218..c0446d6 100644 | |||
89 | --- a/lib/lp/services/auth/configure.zcml | |||
90 | +++ b/lib/lp/services/auth/configure.zcml | |||
91 | @@ -31,5 +31,12 @@ | |||
92 | 31 | path_expression="string:+access-token/${id}" | 31 | path_expression="string:+access-token/${id}" |
93 | 32 | attribute_to_parent="target" /> | 32 | attribute_to_parent="target" /> |
94 | 33 | 33 | ||
95 | 34 | <browser:page | ||
96 | 35 | for="lp.services.auth.interfaces.IAccessTokenTarget" | ||
97 | 36 | name="+access-tokens" | ||
98 | 37 | permission="launchpad.Edit" | ||
99 | 38 | class="lp.services.auth.browser.AccessTokensView" | ||
100 | 39 | template="templates/accesstokentarget-access-tokens.pt" /> | ||
101 | 40 | |||
102 | 34 | <webservice:register module="lp.services.auth.webservice" /> | 41 | <webservice:register module="lp.services.auth.webservice" /> |
103 | 35 | </configure> | 42 | </configure> |
104 | diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py | |||
105 | index 7e20ee0..d18d51c 100644 | |||
106 | --- a/lib/lp/services/auth/interfaces.py | |||
107 | +++ b/lib/lp/services/auth/interfaces.py | |||
108 | @@ -48,44 +48,54 @@ class IAccessToken(Interface): | |||
109 | 48 | id = Int(title=_("ID"), required=True, readonly=True) | 48 | id = Int(title=_("ID"), required=True, readonly=True) |
110 | 49 | 49 | ||
111 | 50 | date_created = exported(Datetime( | 50 | date_created = exported(Datetime( |
113 | 51 | title=_("When the token was created."), required=True, readonly=True)) | 51 | title=_("Creation date"), |
114 | 52 | description=_("When the token was created."), | ||
115 | 53 | required=True, readonly=True)) | ||
116 | 52 | 54 | ||
117 | 53 | owner = exported(PublicPersonChoice( | 55 | owner = exported(PublicPersonChoice( |
119 | 54 | title=_("The person who created the token."), | 56 | title=_("Owner"), |
120 | 57 | description=_("The person who created the token."), | ||
121 | 55 | vocabulary="ValidPersonOrTeam", required=True, readonly=True)) | 58 | vocabulary="ValidPersonOrTeam", required=True, readonly=True)) |
122 | 56 | 59 | ||
123 | 57 | description = exported(TextLine( | 60 | description = exported(TextLine( |
125 | 58 | title=_("A short description of the token."), required=True)) | 61 | title=_("Description"), |
126 | 62 | description=_("A short description of the token."), required=True)) | ||
127 | 59 | 63 | ||
128 | 60 | git_repository = Reference( | 64 | git_repository = Reference( |
130 | 61 | title=_("The Git repository for which the token was issued."), | 65 | title=_("Git repository"), |
131 | 66 | description=_("The Git repository for which the token was issued."), | ||
132 | 62 | # Really IGitRepository, patched in _schema_circular_imports.py. | 67 | # Really IGitRepository, patched in _schema_circular_imports.py. |
133 | 63 | schema=Interface, required=True, readonly=True) | 68 | schema=Interface, required=True, readonly=True) |
134 | 64 | 69 | ||
135 | 65 | target = exported(Reference( | 70 | target = exported(Reference( |
137 | 66 | title=_("The target for which the token was issued."), | 71 | title=_("Target"), |
138 | 72 | description=_("The target for which the token was issued."), | ||
139 | 67 | # Really IAccessTokenTarget, patched in _schema_circular_imports.py. | 73 | # Really IAccessTokenTarget, patched in _schema_circular_imports.py. |
140 | 68 | schema=Interface, required=True, readonly=True)) | 74 | schema=Interface, required=True, readonly=True)) |
141 | 69 | 75 | ||
142 | 70 | scopes = exported(List( | 76 | scopes = exported(List( |
143 | 71 | value_type=Choice(vocabulary=AccessTokenScope), | 77 | value_type=Choice(vocabulary=AccessTokenScope), |
145 | 72 | title=_("A list of scopes granted by the token."), | 78 | title=_("Scopes"), |
146 | 79 | description=_("A list of scopes granted by the token."), | ||
147 | 73 | required=True, readonly=True)) | 80 | required=True, readonly=True)) |
148 | 74 | 81 | ||
149 | 75 | date_last_used = exported(Datetime( | 82 | date_last_used = exported(Datetime( |
151 | 76 | title=_("When the token was last used."), | 83 | title=_("Date last used"), |
152 | 84 | description=_("When the token was last used."), | ||
153 | 77 | required=False, readonly=True)) | 85 | required=False, readonly=True)) |
154 | 78 | 86 | ||
155 | 79 | date_expires = exported(Datetime( | 87 | date_expires = exported(Datetime( |
157 | 80 | title=_("When the token should expire or was revoked."), | 88 | title=_("Expiry date"), |
158 | 89 | description=_("When the token should expire or was revoked."), | ||
159 | 81 | required=False, readonly=True)) | 90 | required=False, readonly=True)) |
160 | 82 | 91 | ||
161 | 83 | is_expired = Bool( | 92 | is_expired = Bool( |
163 | 84 | title=_("Whether this token has expired."), | 93 | description=_("Whether this token has expired."), |
164 | 85 | required=False, readonly=True) | 94 | required=False, readonly=True) |
165 | 86 | 95 | ||
166 | 87 | revoked_by = exported(PublicPersonChoice( | 96 | revoked_by = exported(PublicPersonChoice( |
168 | 88 | title=_("The person who revoked the token, if any."), | 97 | title=_("Revoked by"), |
169 | 98 | description=_("The person who revoked the token, if any."), | ||
170 | 89 | vocabulary="ValidPersonOrTeam", required=False, readonly=True)) | 99 | vocabulary="ValidPersonOrTeam", required=False, readonly=True)) |
171 | 90 | 100 | ||
172 | 91 | def updateLastUsed(): | 101 | def updateLastUsed(): |
173 | @@ -135,6 +145,15 @@ class IAccessTokenSet(Interface): | |||
174 | 135 | by this user. | 145 | by this user. |
175 | 136 | """ | 146 | """ |
176 | 137 | 147 | ||
177 | 148 | def getByTargetAndID(target, token_id, visible_by_user=None): | ||
178 | 149 | """Return the access token with this target and ID, or None. | ||
179 | 150 | |||
180 | 151 | :param target: An `IAccessTokenTarget`. | ||
181 | 152 | :param token_id: An `AccessToken` ID. | ||
182 | 153 | :param visible_by_user: If given, return only access tokens visible | ||
183 | 154 | by this user. | ||
184 | 155 | """ | ||
185 | 156 | |||
186 | 138 | 157 | ||
187 | 139 | class IAccessTokenVerifiedRequest(Interface): | 158 | class IAccessTokenVerifiedRequest(Interface): |
188 | 140 | """Marker interface for a request with a verified access token.""" | 159 | """Marker interface for a request with a verified access token.""" |
189 | diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.html b/lib/lp/services/auth/javascript/tests/test_tokens.html | |||
190 | 141 | new file mode 100644 | 160 | new file mode 100644 |
191 | index 0000000..f7887fb | |||
192 | --- /dev/null | |||
193 | +++ b/lib/lp/services/auth/javascript/tests/test_tokens.html | |||
194 | @@ -0,0 +1,121 @@ | |||
195 | 1 | <!DOCTYPE html> | ||
196 | 2 | <!-- | ||
197 | 3 | Copyright 2021 Canonical Ltd. This software is licensed under the | ||
198 | 4 | GNU Affero General Public License version 3 (see the file LICENSE). | ||
199 | 5 | --> | ||
200 | 6 | |||
201 | 7 | <html> | ||
202 | 8 | <head> | ||
203 | 9 | <title>Personal access token widget tests</title> | ||
204 | 10 | |||
205 | 11 | <!-- YUI and test setup --> | ||
206 | 12 | <script type="text/javascript" | ||
207 | 13 | src="../../../../../../build/js/yui/yui/yui.js"> | ||
208 | 14 | </script> | ||
209 | 15 | <link rel="stylesheet" | ||
210 | 16 | href="../../../../../../build/js/yui/console/assets/console-core.css" /> | ||
211 | 17 | <link rel="stylesheet" | ||
212 | 18 | href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" /> | ||
213 | 19 | <link rel="stylesheet" | ||
214 | 20 | href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" /> | ||
215 | 21 | |||
216 | 22 | <script type="text/javascript" | ||
217 | 23 | src="../../../../../../build/js/lp/app/testing/testrunner.js"></script> | ||
218 | 24 | <script type="text/javascript" | ||
219 | 25 | src="../../../../../../build/js/lp/app/testing/helpers.js"></script> | ||
220 | 26 | |||
221 | 27 | <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" /> | ||
222 | 28 | |||
223 | 29 | <!-- Dependencies --> | ||
224 | 30 | <script type="text/javascript" | ||
225 | 31 | src="../../../../../../build/js/lp/app/anim/anim.js"></script> | ||
226 | 32 | <script type="text/javascript" | ||
227 | 33 | src="../../../../../../build/js/lp/app/client.js"></script> | ||
228 | 34 | <script type="text/javascript" | ||
229 | 35 | src="../../../../../../build/js/lp/app/effects/effects.js"></script> | ||
230 | 36 | <script type="text/javascript" | ||
231 | 37 | src="../../../../../../build/js/lp/app/errors.js"></script> | ||
232 | 38 | <script type="text/javascript" | ||
233 | 39 | src="../../../../../../build/js/lp/app/expander.js"></script> | ||
234 | 40 | <script type="text/javascript" | ||
235 | 41 | src="../../../../../../build/js/lp/app/extras/extras.js"></script> | ||
236 | 42 | <script type="text/javascript" | ||
237 | 43 | src="../../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script> | ||
238 | 44 | <script type="text/javascript" | ||
239 | 45 | src="../../../../../../build/js/lp/app/lp.js"></script> | ||
240 | 46 | <script type="text/javascript" | ||
241 | 47 | src="../../../../../../build/js/lp/app/overlay/overlay.js"></script> | ||
242 | 48 | <script type="text/javascript" | ||
243 | 49 | src="../../../../../../build/js/lp/app/testing/mockio.js"></script> | ||
244 | 50 | <script type="text/javascript" | ||
245 | 51 | src="../../../../../../build/js/lp/app/ui/ui.js"></script> | ||
246 | 52 | |||
247 | 53 | <!-- The module under test. --> | ||
248 | 54 | <script type="text/javascript" src="../tokens.js"></script> | ||
249 | 55 | |||
250 | 56 | <!-- The test suite --> | ||
251 | 57 | <script type="text/javascript" src="test_tokens.js"></script> | ||
252 | 58 | |||
253 | 59 | <script id="fixture-template" type="text/x-template"> | ||
254 | 60 | <div id="create-token"> | ||
255 | 61 | <table class="form"> | ||
256 | 62 | <tr> | ||
257 | 63 | <td> | ||
258 | 64 | <label for="field.description">Description:</label> | ||
259 | 65 | <input class="textType" id="field.description" | ||
260 | 66 | name="field.description" size="20" type="text" value="" /> | ||
261 | 67 | </td> | ||
262 | 68 | <td> | ||
263 | 69 | <label for="field.scopes">Scopes:</label> | ||
264 | 70 | <label for="field.scopes.0"> | ||
265 | 71 | <input class="checkboxType" id="field.scopes.0" | ||
266 | 72 | name="field.scopes" type="checkbox" | ||
267 | 73 | value="REPOSITORY_BUILD_STATUS" | ||
268 | 74 | /> repository:build_status | ||
269 | 75 | </label> | ||
270 | 76 | <br /> | ||
271 | 77 | <label for="field.scopes.1"> | ||
272 | 78 | <input class="checkboxType" id="field.scopes.1" | ||
273 | 79 | name="field.scopes" type="checkbox" | ||
274 | 80 | value="REPOSITORY_PUSH" /> repository:push | ||
275 | 81 | </label> | ||
276 | 82 | <input name="field.scopes.empty-marker" type="hidden" | ||
277 | 83 | value="1" /> | ||
278 | 84 | </td> | ||
279 | 85 | <td> | ||
280 | 86 | <label for="field.date_expires">Expiry date:</label> | ||
281 | 87 | <input size="19" type="text" class="yui2-calendar withtime" | ||
282 | 88 | id="field.date_expires" name="field.date_expires" /> | ||
283 | 89 | in time zone: UTC | ||
284 | 90 | </td> | ||
285 | 91 | </tr> | ||
286 | 92 | </table> | ||
287 | 93 | |||
288 | 94 | <p> | ||
289 | 95 | <input id="create-token-button" class="js-only" | ||
290 | 96 | type="button" value="Create token" /> | ||
291 | 97 | <img class="spinner hidden" src="/@@/spinner" alt="Loading..." /> | ||
292 | 98 | </p> | ||
293 | 99 | <div id="new-token-information" class="hidden"> | ||
294 | 100 | <p>Your new personal access token is:</p> | ||
295 | 101 | <pre id="new-token-secret" class="subordinate"></pre> | ||
296 | 102 | <p> | ||
297 | 103 | Launchpad will not show you this again, so make sure to save it | ||
298 | 104 | now. | ||
299 | 105 | </p> | ||
300 | 106 | </div> | ||
301 | 107 | </div> | ||
302 | 108 | |||
303 | 109 | <table class="listing access-tokens-table"> | ||
304 | 110 | <tbody id="access-tokens-tbody" /> | ||
305 | 111 | </table> | ||
306 | 112 | </script> | ||
307 | 113 | </head> | ||
308 | 114 | <body class="yui3-skin-sam"> | ||
309 | 115 | <ul id="suites"> | ||
310 | 116 | <li>lp.services.auth.tokens.test</li> | ||
311 | 117 | </ul> | ||
312 | 118 | |||
313 | 119 | <div id="fixture" /> | ||
314 | 120 | </body> | ||
315 | 121 | </html> | ||
316 | diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.js b/lib/lp/services/auth/javascript/tests/test_tokens.js | |||
317 | 0 | new file mode 100644 | 122 | new file mode 100644 |
318 | index 0000000..347b3d3 | |||
319 | --- /dev/null | |||
320 | +++ b/lib/lp/services/auth/javascript/tests/test_tokens.js | |||
321 | @@ -0,0 +1,181 @@ | |||
322 | 1 | /* Copyright 2021 Canonical Ltd. This software is licensed under the | ||
323 | 2 | * GNU Affero General Public License version 3 (see the file LICENSE). */ | ||
324 | 3 | |||
325 | 4 | YUI.add('lp.services.auth.tokens.test', function (Y) { | ||
326 | 5 | |||
327 | 6 | var tests = Y.namespace('lp.services.auth.tokens.test'); | ||
328 | 7 | tests.suite = new Y.Test.Suite('lp.services.auth.tokens Tests'); | ||
329 | 8 | |||
330 | 9 | tests.suite.add(new Y.Test.Case({ | ||
331 | 10 | name: 'lp.services.auth.tokens_tests', | ||
332 | 11 | |||
333 | 12 | setUp: function () { | ||
334 | 13 | this.node = Y.Node.create(Y.one('#fixture-template').getContent()); | ||
335 | 14 | Y.one('#fixture').append(this.node); | ||
336 | 15 | this.widget = new Y.lp.services.auth.tokens.CreateTokenWidget({ | ||
337 | 16 | srcNode: Y.one('#create-token'), | ||
338 | 17 | target_uri: '/api/devel/repo' | ||
339 | 18 | }); | ||
340 | 19 | this.mockio = new Y.lp.testing.mockio.MockIo(); | ||
341 | 20 | this.widget.client.io_provider = this.mockio; | ||
342 | 21 | this.old_error_method = Y.lp.app.errors.display_error; | ||
343 | 22 | }, | ||
344 | 23 | |||
345 | 24 | tearDown: function () { | ||
346 | 25 | this.widget.destroy(); | ||
347 | 26 | Y.one('#fixture').empty(); | ||
348 | 27 | Y.lp.app.errors.display_error = this.old_error_method; | ||
349 | 28 | }, | ||
350 | 29 | |||
351 | 30 | test_library_exists: function () { | ||
352 | 31 | Y.Assert.isObject(Y.lp.services.auth.tokens, | ||
353 | 32 | 'Could not locate the lp.services.auth.tokens module'); | ||
354 | 33 | }, | ||
355 | 34 | |||
356 | 35 | test_widget_can_be_instantiated: function () { | ||
357 | 36 | Y.Assert.isInstanceOf( | ||
358 | 37 | Y.lp.services.auth.tokens.CreateTokenWidget, | ||
359 | 38 | this.widget, 'Widget failed to be instantiated'); | ||
360 | 39 | }, | ||
361 | 40 | |||
362 | 41 | test_create_no_description: function () { | ||
363 | 42 | var error_shown = false; | ||
364 | 43 | Y.lp.app.errors.display_error = function(flash_node, msg) { | ||
365 | 44 | Y.Assert.areEqual( | ||
366 | 45 | Y.one('[name="field.description"]'), flash_node); | ||
367 | 46 | Y.Assert.areEqual( | ||
368 | 47 | 'A personal access token must have a description.', msg); | ||
369 | 48 | error_shown = true; | ||
370 | 49 | }; | ||
371 | 50 | |||
372 | 51 | this.widget.render(); | ||
373 | 52 | Y.one('#create-token-button').simulate('click'); | ||
374 | 53 | Y.Assert.isTrue(error_shown); | ||
375 | 54 | }, | ||
376 | 55 | |||
377 | 56 | test_create_no_scopes: function () { | ||
378 | 57 | var error_shown = false; | ||
379 | 58 | Y.lp.app.errors.display_error = function(flash_node, msg) { | ||
380 | 59 | Y.Assert.isNull(flash_node); | ||
381 | 60 | Y.Assert.areEqual( | ||
382 | 61 | 'A personal access token must have scopes.', msg); | ||
383 | 62 | error_shown = true; | ||
384 | 63 | }; | ||
385 | 64 | |||
386 | 65 | Y.one('[name="field.description"]').set('value', 'Test'); | ||
387 | 66 | this.widget.render(); | ||
388 | 67 | Y.one('#create-token-button').simulate('click'); | ||
389 | 68 | Y.Assert.isTrue(error_shown); | ||
390 | 69 | }, | ||
391 | 70 | |||
392 | 71 | test_create_failure: function () { | ||
393 | 72 | var error_shown = false; | ||
394 | 73 | Y.lp.app.errors.display_error = function(flash_node, msg) { | ||
395 | 74 | Y.Assert.isNull(flash_node); | ||
396 | 75 | Y.Assert.areEqual( | ||
397 | 76 | 'Failed to create personal access token.', msg); | ||
398 | 77 | error_shown = true; | ||
399 | 78 | }; | ||
400 | 79 | |||
401 | 80 | Y.one('[name="field.description"]').set( | ||
402 | 81 | 'value', 'Test description'); | ||
403 | 82 | Y.all('[name="field.scopes"]').item(1).set('checked', true); | ||
404 | 83 | this.widget.render(); | ||
405 | 84 | Y.one('#create-token-button').simulate('click'); | ||
406 | 85 | Y.Assert.isFalse(error_shown); | ||
407 | 86 | |||
408 | 87 | this.mockio.failure(); | ||
409 | 88 | Y.Assert.isTrue(error_shown); | ||
410 | 89 | }, | ||
411 | 90 | |||
412 | 91 | test_create: function () { | ||
413 | 92 | var error_shown = false; | ||
414 | 93 | Y.lp.app.errors.display_error = function(flash_node, msg) { | ||
415 | 94 | error_shown = true; | ||
416 | 95 | }; | ||
417 | 96 | |||
418 | 97 | Y.one('[name="field.description"]').set( | ||
419 | 98 | 'value', 'Test description'); | ||
420 | 99 | Y.all('[name="field.scopes"]').item(1).set('checked', true); | ||
421 | 100 | this.widget.render(); | ||
422 | 101 | Y.one('#create-token-button').simulate('click'); | ||
423 | 102 | Y.Assert.isFalse(error_shown); | ||
424 | 103 | Y.Assert.areEqual(1, this.mockio.requests.length); | ||
425 | 104 | Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url); | ||
426 | 105 | Y.Assert.areEqual('POST', this.mockio.last_request.config.method); | ||
427 | 106 | Y.Assert.areEqual( | ||
428 | 107 | 'ws.op=issueAccessToken' + | ||
429 | 108 | '&description=Test%20description' + | ||
430 | 109 | '&scopes=repository%3Apush', | ||
431 | 110 | this.mockio.last_request.config.data); | ||
432 | 111 | Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden')); | ||
433 | 112 | |||
434 | 113 | this.mockio.success({ | ||
435 | 114 | responseHeaders: {'Content-Type': 'application/json'}, | ||
436 | 115 | responseText: Y.JSON.stringify('test-secret') | ||
437 | 116 | }); | ||
438 | 117 | Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden')); | ||
439 | 118 | Y.Assert.areEqual( | ||
440 | 119 | 'test-secret', Y.one('#new-token-secret').get('text')); | ||
441 | 120 | Y.Assert.isFalse( | ||
442 | 121 | Y.one('#new-token-information').hasClass('hidden')); | ||
443 | 122 | var token_row = Y.one('#access-tokens-tbody tr'); | ||
444 | 123 | Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even')); | ||
445 | 124 | Y.ArrayAssert.itemsAreEqual( | ||
446 | 125 | ['Test description', 'repository:push', 'a moment ago', | ||
447 | 126 | '', 'Never', ''], | ||
448 | 127 | token_row.all('td').map(function (node) { | ||
449 | 128 | return node.get('text'); | ||
450 | 129 | })); | ||
451 | 130 | }, | ||
452 | 131 | |||
453 | 132 | test_create_with_expiry: function () { | ||
454 | 133 | var error_shown = false; | ||
455 | 134 | Y.lp.app.errors.display_error = function(flash_node, msg) { | ||
456 | 135 | error_shown = true; | ||
457 | 136 | }; | ||
458 | 137 | |||
459 | 138 | Y.one('[name="field.description"]').set( | ||
460 | 139 | 'value', 'Test description'); | ||
461 | 140 | Y.all('[name="field.scopes"]').item(0).set('checked', true); | ||
462 | 141 | Y.one('[name="field.date_expires"]').set('value', '2021-01-01'); | ||
463 | 142 | this.widget.render(); | ||
464 | 143 | Y.one('#create-token-button').simulate('click'); | ||
465 | 144 | Y.Assert.isFalse(error_shown); | ||
466 | 145 | Y.Assert.areEqual(1, this.mockio.requests.length); | ||
467 | 146 | Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url); | ||
468 | 147 | Y.Assert.areEqual('POST', this.mockio.last_request.config.method); | ||
469 | 148 | Y.Assert.areEqual( | ||
470 | 149 | 'ws.op=issueAccessToken' + | ||
471 | 150 | '&description=Test%20description' + | ||
472 | 151 | '&scopes=repository%3Abuild_status' + | ||
473 | 152 | '&date_expires=2021-01-01', | ||
474 | 153 | this.mockio.last_request.config.data); | ||
475 | 154 | Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden')); | ||
476 | 155 | |||
477 | 156 | this.mockio.success({ | ||
478 | 157 | responseHeaders: {'Content-Type': 'application/json'}, | ||
479 | 158 | responseText: Y.JSON.stringify('test-secret') | ||
480 | 159 | }); | ||
481 | 160 | Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden')); | ||
482 | 161 | Y.Assert.areEqual( | ||
483 | 162 | 'test-secret', Y.one('#new-token-secret').get('text')); | ||
484 | 163 | Y.Assert.isFalse( | ||
485 | 164 | Y.one('#new-token-information').hasClass('hidden')); | ||
486 | 165 | var token_row = Y.one('#access-tokens-tbody tr'); | ||
487 | 166 | Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even')); | ||
488 | 167 | Y.ArrayAssert.itemsAreEqual( | ||
489 | 168 | ['Test description', 'repository:build_status', 'a moment ago', | ||
490 | 169 | '', '2021-01-01', ''], | ||
491 | 170 | token_row.all('td').map(function (node) { | ||
492 | 171 | return node.get('text'); | ||
493 | 172 | })); | ||
494 | 173 | } | ||
495 | 174 | })); | ||
496 | 175 | |||
497 | 176 | }, '0.1', { | ||
498 | 177 | requires: [ | ||
499 | 178 | 'json-stringify', 'node-event-simulate', 'test', | ||
500 | 179 | 'lp.services.auth.tokens', 'lp.testing.mockio' | ||
501 | 180 | ] | ||
502 | 181 | }); | ||
503 | diff --git a/lib/lp/services/auth/javascript/tokens.js b/lib/lp/services/auth/javascript/tokens.js | |||
504 | 0 | new file mode 100644 | 182 | new file mode 100644 |
505 | index 0000000..81b4d4b | |||
506 | --- /dev/null | |||
507 | +++ b/lib/lp/services/auth/javascript/tokens.js | |||
508 | @@ -0,0 +1,151 @@ | |||
509 | 1 | /* Copyright 2021 Canonical Ltd. This software is licensed under the | ||
510 | 2 | * GNU Affero General Public License version 3 (see the file LICENSE). | ||
511 | 3 | * | ||
512 | 4 | * Personal access token widgets. | ||
513 | 5 | * | ||
514 | 6 | * @module lp.services.auth.tokens | ||
515 | 7 | * @requires node, widget, lp.app.errors, lp.client, lp.extras, lp.ui-base | ||
516 | 8 | */ | ||
517 | 9 | |||
518 | 10 | YUI.add('lp.services.auth.tokens', function(Y) { | ||
519 | 11 | var module = Y.namespace('lp.services.auth.tokens'); | ||
520 | 12 | |||
521 | 13 | var CreateTokenWidget = function() { | ||
522 | 14 | CreateTokenWidget.superclass.constructor.apply(this, arguments); | ||
523 | 15 | }; | ||
524 | 16 | |||
525 | 17 | CreateTokenWidget.NAME = 'create-token-widget'; | ||
526 | 18 | |||
527 | 19 | CreateTokenWidget.ATTRS = { | ||
528 | 20 | /** | ||
529 | 21 | * The URI for the target for new tokens. | ||
530 | 22 | * | ||
531 | 23 | * @attribute target_uri | ||
532 | 24 | * @type String | ||
533 | 25 | * @default null | ||
534 | 26 | */ | ||
535 | 27 | target_uri: { | ||
536 | 28 | value: null | ||
537 | 29 | } | ||
538 | 30 | }; | ||
539 | 31 | |||
540 | 32 | Y.extend(CreateTokenWidget, Y.Widget, { | ||
541 | 33 | initializer: function(cfg) { | ||
542 | 34 | this.client = new Y.lp.client.Launchpad(); | ||
543 | 35 | this.set('target_uri', cfg.target_uri); | ||
544 | 36 | }, | ||
545 | 37 | |||
546 | 38 | /** | ||
547 | 39 | * Show the spinner. | ||
548 | 40 | * | ||
549 | 41 | * @method showSpinner | ||
550 | 42 | */ | ||
551 | 43 | showSpinner: function() { | ||
552 | 44 | this.get('srcNode').all('.spinner').removeClass('hidden'); | ||
553 | 45 | }, | ||
554 | 46 | |||
555 | 47 | /** | ||
556 | 48 | * Hide the spinner. | ||
557 | 49 | * | ||
558 | 50 | * @method hideSpinner | ||
559 | 51 | */ | ||
560 | 52 | hideSpinner: function() { | ||
561 | 53 | this.get('srcNode').all('.spinner').addClass('hidden'); | ||
562 | 54 | }, | ||
563 | 55 | |||
564 | 56 | /** | ||
565 | 57 | * Create a new token. | ||
566 | 58 | * | ||
567 | 59 | * @method createToken | ||
568 | 60 | */ | ||
569 | 61 | createToken: function() { | ||
570 | 62 | var container = this.get('srcNode'); | ||
571 | 63 | var description_node = container.one('[name="field.description"]'); | ||
572 | 64 | var description = description_node.get('value'); | ||
573 | 65 | if (description === '') { | ||
574 | 66 | Y.lp.app.errors.display_error( | ||
575 | 67 | description_node, | ||
576 | 68 | 'A personal access token must have a description.'); | ||
577 | 69 | return; | ||
578 | 70 | } | ||
579 | 71 | var scopes = container.all('[name="field.scopes"]') | ||
580 | 72 | .filter(':checked') | ||
581 | 73 | .map(function (node) { | ||
582 | 74 | var node_id = node.get('id'); | ||
583 | 75 | var label = container.one('label[for="' + node_id + '"]'); | ||
584 | 76 | return label.get('text').trim(); | ||
585 | 77 | }); | ||
586 | 78 | if (scopes.length === 0) { | ||
587 | 79 | Y.lp.app.errors.display_error( | ||
588 | 80 | null, 'A personal access token must have scopes.'); | ||
589 | 81 | return; | ||
590 | 82 | } | ||
591 | 83 | var date_expires = container | ||
592 | 84 | .one('[name="field.date_expires"]').get('value'); | ||
593 | 85 | var self = this; | ||
594 | 86 | var config = { | ||
595 | 87 | on: { | ||
596 | 88 | start: function() { | ||
597 | 89 | self.showSpinner(); | ||
598 | 90 | }, | ||
599 | 91 | end: function() { | ||
600 | 92 | self.hideSpinner(); | ||
601 | 93 | }, | ||
602 | 94 | success: function(response) { | ||
603 | 95 | container.one('#new-token-secret') | ||
604 | 96 | .set('text', response); | ||
605 | 97 | container.one('#new-token-information') | ||
606 | 98 | .removeClass('hidden'); | ||
607 | 99 | var tokens_tbody = Y.one('#access-tokens-tbody'); | ||
608 | 100 | tokens_tbody.append(Y.Node.create('<tr />') | ||
609 | 101 | .addClass( | ||
610 | 102 | tokens_tbody.all('tr').size() % 2 | ||
611 | 103 | ? Y.lp.ui.CSS_ODD : Y.lp.ui.CSS_EVEN) | ||
612 | 104 | .append(Y.Node.create('<td />') | ||
613 | 105 | .set('text', description)) | ||
614 | 106 | .append(Y.Node.create('<td />') | ||
615 | 107 | .set('text', scopes.join(', '))) | ||
616 | 108 | .append(Y.Node.create('<td />') | ||
617 | 109 | .set('text', 'a moment ago')) | ||
618 | 110 | .append(Y.Node.create('<td />')) | ||
619 | 111 | .append(Y.Node.create('<td />') | ||
620 | 112 | .set('text', | ||
621 | 113 | date_expires !== '' | ||
622 | 114 | ? date_expires : 'Never')) | ||
623 | 115 | .append(Y.Node.create('<td />'))); | ||
624 | 116 | }, | ||
625 | 117 | failure: function(ignore, response, args) { | ||
626 | 118 | Y.lp.app.errors.display_error( | ||
627 | 119 | null, 'Failed to create personal access token.'); | ||
628 | 120 | } | ||
629 | 121 | }, | ||
630 | 122 | parameters: { | ||
631 | 123 | description: description, | ||
632 | 124 | scopes: scopes | ||
633 | 125 | } | ||
634 | 126 | }; | ||
635 | 127 | if (date_expires !== "") { | ||
636 | 128 | config.parameters.date_expires = date_expires; | ||
637 | 129 | } | ||
638 | 130 | this.client.named_post( | ||
639 | 131 | this.get('target_uri'), 'issueAccessToken', config); | ||
640 | 132 | }, | ||
641 | 133 | |||
642 | 134 | bindUI: function() { | ||
643 | 135 | this.constructor.superclass.bindUI.call(this); | ||
644 | 136 | var create_token_button = this.get('srcNode') | ||
645 | 137 | .one('#create-token-button'); | ||
646 | 138 | if (Y.Lang.isValue(create_token_button)) { | ||
647 | 139 | var self = this; | ||
648 | 140 | create_token_button.on('click', function(e) { | ||
649 | 141 | e.halt(); | ||
650 | 142 | self.createToken(); | ||
651 | 143 | }); | ||
652 | 144 | } | ||
653 | 145 | } | ||
654 | 146 | }); | ||
655 | 147 | |||
656 | 148 | module.CreateTokenWidget = CreateTokenWidget; | ||
657 | 149 | }, '0.1', {'requires': [ | ||
658 | 150 | 'node', 'widget', 'lp.app.errors', 'lp.client', 'lp.extras', 'lp.ui-base' | ||
659 | 151 | ]}); | ||
660 | diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py | |||
661 | index 39a6684..b1cc7fc 100644 | |||
662 | --- a/lib/lp/services/auth/model.py | |||
663 | +++ b/lib/lp/services/auth/model.py | |||
664 | @@ -186,7 +186,16 @@ class AccessTokenSet: | |||
665 | 186 | removeSecurityProxy(ids)._get_select()))) | 186 | removeSecurityProxy(ids)._get_select()))) |
666 | 187 | else: | 187 | else: |
667 | 188 | raise TypeError("Unsupported target: {!r}".format(target)) | 188 | raise TypeError("Unsupported target: {!r}".format(target)) |
669 | 189 | return IStore(AccessToken).find(AccessToken, *clauses) | 189 | clauses.append(Or( |
670 | 190 | AccessToken.date_expires == None, | ||
671 | 191 | AccessToken.date_expires > UTC_NOW)) | ||
672 | 192 | return IStore(AccessToken).find(AccessToken, *clauses).order_by( | ||
673 | 193 | AccessToken.date_created) | ||
674 | 194 | |||
675 | 195 | def getByTargetAndID(self, target, token_id, visible_by_user=None): | ||
676 | 196 | """See `IAccessTokenSet`.""" | ||
677 | 197 | return self.findByTarget(target, visible_by_user=visible_by_user).find( | ||
678 | 198 | id=token_id).one() | ||
679 | 190 | 199 | ||
680 | 191 | 200 | ||
681 | 192 | class AccessTokenTargetMixin: | 201 | class AccessTokenTargetMixin: |
682 | diff --git a/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt | |||
683 | 193 | new file mode 100644 | 202 | new file mode 100644 |
684 | index 0000000..6d85df0 | |||
685 | --- /dev/null | |||
686 | +++ b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt | |||
687 | @@ -0,0 +1,122 @@ | |||
688 | 1 | <html | ||
689 | 2 | xmlns="http://www.w3.org/1999/xhtml" | ||
690 | 3 | xmlns:tal="http://xml.zope.org/namespaces/tal" | ||
691 | 4 | xmlns:metal="http://xml.zope.org/namespaces/metal" | ||
692 | 5 | xmlns:i18n="http://xml.zope.org/namespaces/i18n" | ||
693 | 6 | metal:use-macro="view/macro:page/main_only" | ||
694 | 7 | i18n:domain="launchpad" | ||
695 | 8 | > | ||
696 | 9 | <body> | ||
697 | 10 | |||
698 | 11 | <metal:block fill-slot="head_epilogue"> | ||
699 | 12 | <style type="text/css"> | ||
700 | 13 | .js-only { | ||
701 | 14 | display: none; | ||
702 | 15 | } | ||
703 | 16 | .yui3-js-enabled .js-only { | ||
704 | 17 | display: inline; | ||
705 | 18 | } | ||
706 | 19 | </style> | ||
707 | 20 | |||
708 | 21 | <script type="text/javascript"> | ||
709 | 22 | LPJS.use('node', 'lp.services.auth.tokens', function(Y) { | ||
710 | 23 | Y.on('domready', function() { | ||
711 | 24 | var ns = Y.lp.services.auth.tokens; | ||
712 | 25 | var create_token_widget = new ns.CreateTokenWidget({ | ||
713 | 26 | srcNode: Y.one('#create-token'), | ||
714 | 27 | target_uri: LP.cache.context.self_link | ||
715 | 28 | }); | ||
716 | 29 | create_token_widget.render(); | ||
717 | 30 | }); | ||
718 | 31 | }); | ||
719 | 32 | </script> | ||
720 | 33 | </metal:block> | ||
721 | 34 | |||
722 | 35 | <div metal:fill-slot="main"> | ||
723 | 36 | <p>Personal access tokens allow using certain parts of the Launchpad API.</p> | ||
724 | 37 | |||
725 | 38 | <h2>Create a token</h2> | ||
726 | 39 | <div id="create-token"> | ||
727 | 40 | <metal:form use-macro="context/@@launchpad_form/form"> | ||
728 | 41 | <metal:formbody fill-slot="widgets"> | ||
729 | 42 | <table class="form"> | ||
730 | 43 | <tal:widget define="widget nocall:view/widgets/description"> | ||
731 | 44 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | ||
732 | 45 | </tal:widget> | ||
733 | 46 | <tal:widget define="widget nocall:view/widgets/scopes"> | ||
734 | 47 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | ||
735 | 48 | </tal:widget> | ||
736 | 49 | <tal:widget define="widget nocall:view/widgets/date_expires"> | ||
737 | 50 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | ||
738 | 51 | </tal:widget> | ||
739 | 52 | </table> | ||
740 | 53 | </metal:formbody> | ||
741 | 54 | |||
742 | 55 | <metal:buttons fill-slot="buttons"> | ||
743 | 56 | <p> | ||
744 | 57 | <input id="create-token-button" class="js-only" | ||
745 | 58 | type="button" value="Create token" /> | ||
746 | 59 | <img class="spinner hidden" src="/@@/spinner" alt="Loading..." /> | ||
747 | 60 | <noscript><strong> | ||
748 | 61 | Creating personal access tokens requires JavaScript. | ||
749 | 62 | </strong></noscript> | ||
750 | 63 | </p> | ||
751 | 64 | <div id="new-token-information" class="hidden"> | ||
752 | 65 | <p>Your new personal access token is:</p> | ||
753 | 66 | <pre id="new-token-secret" class="subordinate"></pre> | ||
754 | 67 | <p> | ||
755 | 68 | Launchpad will not show you this again, so make sure to save it | ||
756 | 69 | now. | ||
757 | 70 | </p> | ||
758 | 71 | </div> | ||
759 | 72 | </metal:buttons> | ||
760 | 73 | </metal:form> | ||
761 | 74 | </div> | ||
762 | 75 | |||
763 | 76 | <h2>Active tokens</h2> | ||
764 | 77 | <table class="listing access-tokens-table" | ||
765 | 78 | style="max-width: 80em;"> | ||
766 | 79 | <thead> | ||
767 | 80 | <tr> | ||
768 | 81 | <th>Description</th> | ||
769 | 82 | <th>Scopes</th> | ||
770 | 83 | <th>Created</th> | ||
771 | 84 | <th>Last used</th> | ||
772 | 85 | <th>Expires</th> | ||
773 | 86 | <th><tal:comment condition="nothing">Revoke button</tal:comment></th> | ||
774 | 87 | </tr> | ||
775 | 88 | </thead> | ||
776 | 89 | <tbody id="access-tokens-tbody"> | ||
777 | 90 | <tr tal:repeat="token view/access_tokens" | ||
778 | 91 | tal:attributes=" | ||
779 | 92 | class python: 'yui3-lazr-even' if repeat['token'].even() | ||
780 | 93 | else 'yui3-lazr-odd'; | ||
781 | 94 | token-id token/id"> | ||
782 | 95 | <td tal:content="token/description" /> | ||
783 | 96 | <td tal:content=" | ||
784 | 97 | python: ', '.join(scope.title for scope in token.scopes)" /> | ||
785 | 98 | <td tal:content=" | ||
786 | 99 | structure token/date_created/fmt:approximatedatetitle" /> | ||
787 | 100 | <td tal:content=" | ||
788 | 101 | structure token/date_last_used/fmt:approximatedatetitle" /> | ||
789 | 102 | <td> | ||
790 | 103 | <tal:expires | ||
791 | 104 | condition="token/date_expires" | ||
792 | 105 | replace=" | ||
793 | 106 | structure token/date_expires/fmt:approximatedatetitle" /> | ||
794 | 107 | <span tal:condition="not: token/date_expires">Never</span> | ||
795 | 108 | </td> | ||
796 | 109 | <td> | ||
797 | 110 | <form method="post" tal:attributes="name string:revoke-${token/id}"> | ||
798 | 111 | <input type="hidden" name="token_id" | ||
799 | 112 | tal:attributes="value token/id" /> | ||
800 | 113 | <input type="submit" name="field.actions.revoke" value="Revoke" /> | ||
801 | 114 | </form> | ||
802 | 115 | </td> | ||
803 | 116 | </tr> | ||
804 | 117 | </tbody> | ||
805 | 118 | </table> | ||
806 | 119 | </div> | ||
807 | 120 | |||
808 | 121 | </body> | ||
809 | 122 | </html> | ||
810 | diff --git a/lib/lp/services/auth/tests/test_browser.py b/lib/lp/services/auth/tests/test_browser.py | |||
811 | 0 | new file mode 100644 | 123 | new file mode 100644 |
812 | index 0000000..6ec0b70 | |||
813 | --- /dev/null | |||
814 | +++ b/lib/lp/services/auth/tests/test_browser.py | |||
815 | @@ -0,0 +1,151 @@ | |||
816 | 1 | # Copyright 2021 Canonical Ltd. This software is licensed under the | ||
817 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
818 | 3 | |||
819 | 4 | """Test personal access token views.""" | ||
820 | 5 | |||
821 | 6 | import re | ||
822 | 7 | |||
823 | 8 | import soupmatchers | ||
824 | 9 | from testtools.matchers import ( | ||
825 | 10 | Equals, | ||
826 | 11 | Is, | ||
827 | 12 | MatchesAll, | ||
828 | 13 | MatchesListwise, | ||
829 | 14 | MatchesStructure, | ||
830 | 15 | Not, | ||
831 | 16 | ) | ||
832 | 17 | from zope.component import getUtility | ||
833 | 18 | from zope.security.proxy import removeSecurityProxy | ||
834 | 19 | |||
835 | 20 | from lp.services.webapp.interfaces import IPlacelessAuthUtility | ||
836 | 21 | from lp.services.webapp.publisher import canonical_url | ||
837 | 22 | from lp.testing import ( | ||
838 | 23 | login_person, | ||
839 | 24 | TestCaseWithFactory, | ||
840 | 25 | ) | ||
841 | 26 | from lp.testing.layers import DatabaseFunctionalLayer | ||
842 | 27 | from lp.testing.views import create_view | ||
843 | 28 | |||
844 | 29 | |||
845 | 30 | breadcrumbs_tag = soupmatchers.Tag( | ||
846 | 31 | "breadcrumbs", "ol", attrs={"class": "breadcrumbs"}) | ||
847 | 32 | tokens_page_crumb_tag = soupmatchers.Tag( | ||
848 | 33 | "tokens page breadcrumb", "li", text=re.compile(r"Personal access tokens")) | ||
849 | 34 | token_listing_constants = soupmatchers.HTMLContains( | ||
850 | 35 | soupmatchers.Within(breadcrumbs_tag, tokens_page_crumb_tag)) | ||
851 | 36 | token_listing_tag = soupmatchers.Tag( | ||
852 | 37 | "tokens table", "table", attrs={"class": "listing"}) | ||
853 | 38 | |||
854 | 39 | |||
855 | 40 | class TestAccessTokenViewBase: | ||
856 | 41 | |||
857 | 42 | layer = DatabaseFunctionalLayer | ||
858 | 43 | |||
859 | 44 | def setUp(self): | ||
860 | 45 | super().setUp() | ||
861 | 46 | self.target = self.makeTarget() | ||
862 | 47 | self.owner = self.target.owner | ||
863 | 48 | login_person(self.owner) | ||
864 | 49 | |||
865 | 50 | def makeView(self, name, **kwargs): | ||
866 | 51 | # XXX cjwatson 2021-10-19: We need to give the view a | ||
867 | 52 | # LaunchpadPrincipal rather than just a person, since otherwise bits | ||
868 | 53 | # of the navigation menu machinery try to use the scope_url | ||
869 | 54 | # attribute on the principal and fail. This should probably be done | ||
870 | 55 | # in create_view instead, but that approach needs care to avoid | ||
871 | 56 | # adding an extra query to tests that might be sensitive to that. | ||
872 | 57 | principal = getUtility(IPlacelessAuthUtility).getPrincipal( | ||
873 | 58 | self.owner.accountID) | ||
874 | 59 | view = create_view( | ||
875 | 60 | self.target, name, principal=principal, current_request=True, | ||
876 | 61 | **kwargs) | ||
877 | 62 | # To test the breadcrumbs we need a correct traversal stack. | ||
878 | 63 | view.request.traversed_objects = ( | ||
879 | 64 | self.getTraversalStack(self.target) + [view]) | ||
880 | 65 | # The navigation menu machinery needs this to find the view from the | ||
881 | 66 | # request. | ||
882 | 67 | view.request._last_obj_traversed = view | ||
883 | 68 | view.initialize() | ||
884 | 69 | return view | ||
885 | 70 | |||
886 | 71 | def makeTokensAndMatchers(self, count): | ||
887 | 72 | tokens = [ | ||
888 | 73 | self.factory.makeAccessToken(target=self.target)[1] | ||
889 | 74 | for _ in range(count)] | ||
890 | 75 | # There is a row for each token. | ||
891 | 76 | matchers = [] | ||
892 | 77 | for token in tokens: | ||
893 | 78 | row_tag = soupmatchers.Tag( | ||
894 | 79 | "token row", "tr", | ||
895 | 80 | attrs={"token-id": removeSecurityProxy(token).id}) | ||
896 | 81 | column_tags = [ | ||
897 | 82 | soupmatchers.Tag( | ||
898 | 83 | "description", "td", text=token.description), | ||
899 | 84 | soupmatchers.Tag( | ||
900 | 85 | "scopes", "td", | ||
901 | 86 | text=", ".join(scope.title for scope in token.scopes)), | ||
902 | 87 | ] | ||
903 | 88 | matchers.extend([ | ||
904 | 89 | soupmatchers.Within(row_tag, column_tag) | ||
905 | 90 | for column_tag in column_tags]) | ||
906 | 91 | return matchers | ||
907 | 92 | |||
908 | 93 | def test_empty(self): | ||
909 | 94 | self.assertThat( | ||
910 | 95 | self.makeView("+access-tokens")(), | ||
911 | 96 | MatchesAll( | ||
912 | 97 | token_listing_constants, | ||
913 | 98 | soupmatchers.HTMLContains(token_listing_tag))) | ||
914 | 99 | |||
915 | 100 | def test_existing_tokens(self): | ||
916 | 101 | token_matchers = self.makeTokensAndMatchers(10) | ||
917 | 102 | self.assertThat( | ||
918 | 103 | self.makeView("+access-tokens")(), | ||
919 | 104 | MatchesAll( | ||
920 | 105 | token_listing_constants, | ||
921 | 106 | soupmatchers.HTMLContains(token_listing_tag, *token_matchers))) | ||
922 | 107 | |||
923 | 108 | def test_revoke(self): | ||
924 | 109 | tokens = [ | ||
925 | 110 | self.factory.makeAccessToken(target=self.target)[1] | ||
926 | 111 | for _ in range(3)] | ||
927 | 112 | token_ids = [token.id for token in tokens] | ||
928 | 113 | access_tokens_url = canonical_url( | ||
929 | 114 | self.target, view_name="+access-tokens") | ||
930 | 115 | browser = self.getUserBrowser(access_tokens_url, user=self.owner) | ||
931 | 116 | for token_id in token_ids: | ||
932 | 117 | self.assertThat( | ||
933 | 118 | browser.getForm(name="revoke-%s" % token_id).controls, | ||
934 | 119 | MatchesListwise([ | ||
935 | 120 | MatchesStructure.byEquality( | ||
936 | 121 | type="hidden", name="token_id", value=str(token_id)), | ||
937 | 122 | MatchesStructure.byEquality( | ||
938 | 123 | type="submit", name="field.actions.revoke", | ||
939 | 124 | value="Revoke"), | ||
940 | 125 | ])) | ||
941 | 126 | browser.getForm(name="revoke-%s" % token_ids[1]).getControl( | ||
942 | 127 | "Revoke").click() | ||
943 | 128 | login_person(self.owner) | ||
944 | 129 | self.assertEqual(access_tokens_url, browser.url) | ||
945 | 130 | self.assertThat(tokens[0], MatchesStructure( | ||
946 | 131 | id=Equals(token_ids[0]), | ||
947 | 132 | date_expires=Is(None), | ||
948 | 133 | revoked_by=Is(None))) | ||
949 | 134 | self.assertThat(tokens[1], MatchesStructure( | ||
950 | 135 | id=Equals(token_ids[1]), | ||
951 | 136 | date_expires=Not(Is(None)), | ||
952 | 137 | revoked_by=Equals(self.owner))) | ||
953 | 138 | self.assertThat(tokens[2], MatchesStructure( | ||
954 | 139 | id=Equals(token_ids[2]), | ||
955 | 140 | date_expires=Is(None), | ||
956 | 141 | revoked_by=Is(None))) | ||
957 | 142 | |||
958 | 143 | |||
959 | 144 | class TestAccessTokenViewGitRepository( | ||
960 | 145 | TestAccessTokenViewBase, TestCaseWithFactory): | ||
961 | 146 | |||
962 | 147 | def makeTarget(self): | ||
963 | 148 | return self.factory.makeGitRepository() | ||
964 | 149 | |||
965 | 150 | def getTraversalStack(self, obj): | ||
966 | 151 | return [obj.target, obj] | ||
967 | diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py | |||
968 | index c64dbd9..805b02c 100644 | |||
969 | --- a/lib/lp/services/auth/tests/test_model.py | |||
970 | +++ b/lib/lp/services/auth/tests/test_model.py | |||
971 | @@ -251,6 +251,87 @@ class TestAccessTokenSet(TestCaseWithFactory): | |||
972 | 251 | targets[target_index], | 251 | targets[target_index], |
973 | 252 | visible_by_user=owners[owner_index])) | 252 | visible_by_user=owners[owner_index])) |
974 | 253 | 253 | ||
975 | 254 | def test_findByTarget_excludes_expired(self): | ||
976 | 255 | target = self.factory.makeGitRepository() | ||
977 | 256 | _, current_token = self.factory.makeAccessToken(target=target) | ||
978 | 257 | _, expires_soon_token = self.factory.makeAccessToken( | ||
979 | 258 | target=target, | ||
980 | 259 | date_expires=datetime.now(pytz.UTC) + timedelta(hours=1)) | ||
981 | 260 | _, expired_token = self.factory.makeAccessToken( | ||
982 | 261 | target=target, | ||
983 | 262 | date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1)) | ||
984 | 263 | self.assertContentEqual( | ||
985 | 264 | [current_token, expires_soon_token], | ||
986 | 265 | getUtility(IAccessTokenSet).findByTarget(target)) | ||
987 | 266 | |||
988 | 267 | def test_getByTargetAndID(self): | ||
989 | 268 | targets = [self.factory.makeGitRepository() for _ in range(3)] | ||
990 | 269 | tokens = [ | ||
991 | 270 | self.factory.makeAccessToken(target=targets[0])[1], | ||
992 | 271 | self.factory.makeAccessToken(target=targets[0])[1], | ||
993 | 272 | self.factory.makeAccessToken(target=targets[1])[1], | ||
994 | 273 | ] | ||
995 | 274 | self.assertEqual( | ||
996 | 275 | tokens[0], | ||
997 | 276 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
998 | 277 | targets[0], removeSecurityProxy(tokens[0]).id)) | ||
999 | 278 | self.assertEqual( | ||
1000 | 279 | tokens[1], | ||
1001 | 280 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
1002 | 281 | targets[0], removeSecurityProxy(tokens[1]).id)) | ||
1003 | 282 | self.assertIsNone( | ||
1004 | 283 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
1005 | 284 | targets[0], removeSecurityProxy(tokens[2]).id)) | ||
1006 | 285 | |||
1007 | 286 | def test_getByTargetAndID_visible_by_user(self): | ||
1008 | 287 | targets = [self.factory.makeGitRepository() for _ in range(3)] | ||
1009 | 288 | owners = [self.factory.makePerson() for _ in range(3)] | ||
1010 | 289 | tokens = [ | ||
1011 | 290 | self.factory.makeAccessToken( | ||
1012 | 291 | owner=owners[owner_index], target=targets[target_index])[1] | ||
1013 | 292 | for owner_index, target_index in ( | ||
1014 | 293 | (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))] | ||
1015 | 294 | for owner_index, target_index, expected_tokens in ( | ||
1016 | 295 | (0, 0, tokens[:2]), | ||
1017 | 296 | (0, 1, []), | ||
1018 | 297 | (0, 2, []), | ||
1019 | 298 | (1, 0, [tokens[2]]), | ||
1020 | 299 | (1, 1, [tokens[3]]), | ||
1021 | 300 | (1, 2, []), | ||
1022 | 301 | (2, 0, []), | ||
1023 | 302 | (2, 1, [tokens[4]]), | ||
1024 | 303 | (2, 2, []), | ||
1025 | 304 | ): | ||
1026 | 305 | for token in tokens: | ||
1027 | 306 | fetched_token = getUtility(IAccessTokenSet).getByTargetAndID( | ||
1028 | 307 | targets[target_index], removeSecurityProxy(token).id, | ||
1029 | 308 | visible_by_user=owners[owner_index]) | ||
1030 | 309 | if token in expected_tokens: | ||
1031 | 310 | self.assertEqual(token, fetched_token) | ||
1032 | 311 | else: | ||
1033 | 312 | self.assertIsNone(fetched_token) | ||
1034 | 313 | |||
1035 | 314 | def test_getByTargetAndID_excludes_expired(self): | ||
1036 | 315 | target = self.factory.makeGitRepository() | ||
1037 | 316 | _, current_token = self.factory.makeAccessToken(target=target) | ||
1038 | 317 | _, expires_soon_token = self.factory.makeAccessToken( | ||
1039 | 318 | target=target, | ||
1040 | 319 | date_expires=datetime.now(pytz.UTC) + timedelta(hours=1)) | ||
1041 | 320 | _, expired_token = self.factory.makeAccessToken( | ||
1042 | 321 | target=target, | ||
1043 | 322 | date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1)) | ||
1044 | 323 | self.assertEqual( | ||
1045 | 324 | current_token, | ||
1046 | 325 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
1047 | 326 | target, removeSecurityProxy(current_token).id)) | ||
1048 | 327 | self.assertEqual( | ||
1049 | 328 | expires_soon_token, | ||
1050 | 329 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
1051 | 330 | target, removeSecurityProxy(expires_soon_token).id)) | ||
1052 | 331 | self.assertIsNone( | ||
1053 | 332 | getUtility(IAccessTokenSet).getByTargetAndID( | ||
1054 | 333 | target, removeSecurityProxy(expired_token).id)) | ||
1055 | 334 | |||
1056 | 254 | 335 | ||
1057 | 255 | class TestAccessTokenTargetBase: | 336 | class TestAccessTokenTargetBase: |
1058 | 256 | 337 | ||
1059 | diff --git a/lib/lp/services/auth/tests/test_yuitests.py b/lib/lp/services/auth/tests/test_yuitests.py | |||
1060 | 257 | new file mode 100644 | 338 | new file mode 100644 |
1061 | index 0000000..069cc9d | |||
1062 | --- /dev/null | |||
1063 | +++ b/lib/lp/services/auth/tests/test_yuitests.py | |||
1064 | @@ -0,0 +1,23 @@ | |||
1065 | 1 | # Copyright 2021 Canonical Ltd. This software is licensed under the | ||
1066 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1067 | 3 | |||
1068 | 4 | """Run YUI.test tests.""" | ||
1069 | 5 | |||
1070 | 6 | __all__ = [] | ||
1071 | 7 | |||
1072 | 8 | from lp.testing import ( | ||
1073 | 9 | build_yui_unittest_suite, | ||
1074 | 10 | YUIUnitTestCase, | ||
1075 | 11 | ) | ||
1076 | 12 | from lp.testing.layers import YUITestLayer | ||
1077 | 13 | |||
1078 | 14 | |||
1079 | 15 | class AuthYUIUnitTestCase(YUIUnitTestCase): | ||
1080 | 16 | |||
1081 | 17 | layer = YUITestLayer | ||
1082 | 18 | suite_name = "AuthYUIUnitTests" | ||
1083 | 19 | |||
1084 | 20 | |||
1085 | 21 | def test_suite(): | ||
1086 | 22 | app_testing_path = "lp/services/auth" | ||
1087 | 23 | return build_yui_unittest_suite(app_testing_path, AuthYUIUnitTestCase) | ||
1088 | diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py | |||
1089 | index 9b500b4..2856fa0 100644 | |||
1090 | --- a/lib/lp/testing/factory.py | |||
1091 | +++ b/lib/lp/testing/factory.py | |||
1092 | @@ -4538,6 +4538,7 @@ class BareLaunchpadObjectFactory(ObjectFactory): | |||
1093 | 4538 | token = getUtility(IAccessTokenSet).new( | 4538 | token = getUtility(IAccessTokenSet).new( |
1094 | 4539 | secret, owner, description, target, scopes, | 4539 | secret, owner, description, target, scopes, |
1095 | 4540 | date_expires=date_expires) | 4540 | date_expires=date_expires) |
1096 | 4541 | IStore(token).flush() | ||
1097 | 4541 | return secret, token | 4542 | return secret, token |
1098 | 4542 | 4543 | ||
1099 | 4543 | def makeCVE(self, sequence, description=None, | 4544 | def makeCVE(self, sequence, description=None, |