Merge ~cjwatson/launchpad:access-token-ui into launchpad: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)
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
diff --git a/lib/lp/services/auth/browser.py b/lib/lp/services/auth/browser.py
0new file mode 1006440new file mode 100644
index 0000000..5c9a35d
--- /dev/null
+++ b/lib/lp/services/auth/browser.py
@@ -0,0 +1,80 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""UI for personal access tokens."""
5
6__all__ = [
7 "AccessTokensView",
8 ]
9
10from lazr.restful.interface import (
11 copy_field,
12 use_template,
13 )
14from zope.component import getUtility
15from zope.interface import Interface
16
17from lp import _
18from lp.app.browser.launchpadform import (
19 action,
20 LaunchpadFormView,
21 )
22from lp.app.errors import UnexpectedFormData
23from lp.app.widgets.date import DateTimeWidget
24from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
25from lp.services.auth.interfaces import (
26 IAccessToken,
27 IAccessTokenSet,
28 )
29from lp.services.propertycache import cachedproperty
30from lp.services.webapp.publisher import canonical_url
31
32
33class IAccessTokenCreateSchema(Interface):
34 """Schema for creating a personal access token."""
35
36 use_template(IAccessToken, include=[
37 "description",
38 "scopes",
39 ])
40
41 date_expires = copy_field(
42 IAccessToken["date_expires"],
43 description=_("When the token should expire."))
44
45
46class AccessTokensView(LaunchpadFormView):
47
48 schema = IAccessTokenCreateSchema
49 custom_widget_scopes = LabeledMultiCheckBoxWidget
50 custom_widget_date_expires = DateTimeWidget
51
52 @property
53 def label(self):
54 return "Personal access tokens for %s" % self.context.display_name
55
56 page_title = "Personal access tokens"
57
58 @cachedproperty
59 def access_tokens(self):
60 return list(getUtility(IAccessTokenSet).findByTarget(
61 self.context, visible_by_user=self.user))
62
63 @action("Revoke", name="revoke")
64 def revoke_action(self, action, data):
65 form = self.request.form
66 token_id = form.get("token_id")
67 if token_id is None:
68 raise UnexpectedFormData("Missing token_id")
69 try:
70 token_id = int(token_id)
71 except ValueError:
72 raise UnexpectedFormData("token_id is not an integer")
73 token = getUtility(IAccessTokenSet).getByTargetAndID(
74 self.context, token_id, visible_by_user=self.user)
75 if token is not None:
76 token.revoke(self.user)
77 self.request.response.addInfoNotification(
78 "Token revoked successfully.")
79 self.request.response.redirect(
80 canonical_url(self.context, view_name="+access-tokens"))
diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
index f300218..c0446d6 100644
--- a/lib/lp/services/auth/configure.zcml
+++ b/lib/lp/services/auth/configure.zcml
@@ -31,5 +31,12 @@
31 path_expression="string:+access-token/${id}"31 path_expression="string:+access-token/${id}"
32 attribute_to_parent="target" />32 attribute_to_parent="target" />
3333
34 <browser:page
35 for="lp.services.auth.interfaces.IAccessTokenTarget"
36 name="+access-tokens"
37 permission="launchpad.Edit"
38 class="lp.services.auth.browser.AccessTokensView"
39 template="templates/accesstokentarget-access-tokens.pt" />
40
34 <webservice:register module="lp.services.auth.webservice" />41 <webservice:register module="lp.services.auth.webservice" />
35</configure>42</configure>
diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
index 7e20ee0..d18d51c 100644
--- a/lib/lp/services/auth/interfaces.py
+++ b/lib/lp/services/auth/interfaces.py
@@ -48,44 +48,54 @@ class IAccessToken(Interface):
48 id = Int(title=_("ID"), required=True, readonly=True)48 id = Int(title=_("ID"), required=True, readonly=True)
4949
50 date_created = exported(Datetime(50 date_created = exported(Datetime(
51 title=_("When the token was created."), required=True, readonly=True))51 title=_("Creation date"),
52 description=_("When the token was created."),
53 required=True, readonly=True))
5254
53 owner = exported(PublicPersonChoice(55 owner = exported(PublicPersonChoice(
54 title=_("The person who created the token."),56 title=_("Owner"),
57 description=_("The person who created the token."),
55 vocabulary="ValidPersonOrTeam", required=True, readonly=True))58 vocabulary="ValidPersonOrTeam", required=True, readonly=True))
5659
57 description = exported(TextLine(60 description = exported(TextLine(
58 title=_("A short description of the token."), required=True))61 title=_("Description"),
62 description=_("A short description of the token."), required=True))
5963
60 git_repository = Reference(64 git_repository = Reference(
61 title=_("The Git repository for which the token was issued."),65 title=_("Git repository"),
66 description=_("The Git repository for which the token was issued."),
62 # Really IGitRepository, patched in _schema_circular_imports.py.67 # Really IGitRepository, patched in _schema_circular_imports.py.
63 schema=Interface, required=True, readonly=True)68 schema=Interface, required=True, readonly=True)
6469
65 target = exported(Reference(70 target = exported(Reference(
66 title=_("The target for which the token was issued."),71 title=_("Target"),
72 description=_("The target for which the token was issued."),
67 # Really IAccessTokenTarget, patched in _schema_circular_imports.py.73 # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
68 schema=Interface, required=True, readonly=True))74 schema=Interface, required=True, readonly=True))
6975
70 scopes = exported(List(76 scopes = exported(List(
71 value_type=Choice(vocabulary=AccessTokenScope),77 value_type=Choice(vocabulary=AccessTokenScope),
72 title=_("A list of scopes granted by the token."),78 title=_("Scopes"),
79 description=_("A list of scopes granted by the token."),
73 required=True, readonly=True))80 required=True, readonly=True))
7481
75 date_last_used = exported(Datetime(82 date_last_used = exported(Datetime(
76 title=_("When the token was last used."),83 title=_("Date last used"),
84 description=_("When the token was last used."),
77 required=False, readonly=True))85 required=False, readonly=True))
7886
79 date_expires = exported(Datetime(87 date_expires = exported(Datetime(
80 title=_("When the token should expire or was revoked."),88 title=_("Expiry date"),
89 description=_("When the token should expire or was revoked."),
81 required=False, readonly=True))90 required=False, readonly=True))
8291
83 is_expired = Bool(92 is_expired = Bool(
84 title=_("Whether this token has expired."),93 description=_("Whether this token has expired."),
85 required=False, readonly=True)94 required=False, readonly=True)
8695
87 revoked_by = exported(PublicPersonChoice(96 revoked_by = exported(PublicPersonChoice(
88 title=_("The person who revoked the token, if any."),97 title=_("Revoked by"),
98 description=_("The person who revoked the token, if any."),
89 vocabulary="ValidPersonOrTeam", required=False, readonly=True))99 vocabulary="ValidPersonOrTeam", required=False, readonly=True))
90100
91 def updateLastUsed():101 def updateLastUsed():
@@ -135,6 +145,15 @@ class IAccessTokenSet(Interface):
135 by this user.145 by this user.
136 """146 """
137147
148 def getByTargetAndID(target, token_id, visible_by_user=None):
149 """Return the access token with this target and ID, or None.
150
151 :param target: An `IAccessTokenTarget`.
152 :param token_id: An `AccessToken` ID.
153 :param visible_by_user: If given, return only access tokens visible
154 by this user.
155 """
156
138157
139class IAccessTokenVerifiedRequest(Interface):158class IAccessTokenVerifiedRequest(Interface):
140 """Marker interface for a request with a verified access token."""159 """Marker interface for a request with a verified access token."""
diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.html b/lib/lp/services/auth/javascript/tests/test_tokens.html
141new file mode 100644160new file mode 100644
index 0000000..f7887fb
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.html
@@ -0,0 +1,121 @@
1<!DOCTYPE html>
2<!--
3Copyright 2021 Canonical Ltd. This software is licensed under the
4GNU Affero General Public License version 3 (see the file LICENSE).
5-->
6
7<html>
8 <head>
9 <title>Personal access token widget tests</title>
10
11 <!-- YUI and test setup -->
12 <script type="text/javascript"
13 src="../../../../../../build/js/yui/yui/yui.js">
14 </script>
15 <link rel="stylesheet"
16 href="../../../../../../build/js/yui/console/assets/console-core.css" />
17 <link rel="stylesheet"
18 href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
19 <link rel="stylesheet"
20 href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
21
22 <script type="text/javascript"
23 src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
24 <script type="text/javascript"
25 src="../../../../../../build/js/lp/app/testing/helpers.js"></script>
26
27 <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
28
29 <!-- Dependencies -->
30 <script type="text/javascript"
31 src="../../../../../../build/js/lp/app/anim/anim.js"></script>
32 <script type="text/javascript"
33 src="../../../../../../build/js/lp/app/client.js"></script>
34 <script type="text/javascript"
35 src="../../../../../../build/js/lp/app/effects/effects.js"></script>
36 <script type="text/javascript"
37 src="../../../../../../build/js/lp/app/errors.js"></script>
38 <script type="text/javascript"
39 src="../../../../../../build/js/lp/app/expander.js"></script>
40 <script type="text/javascript"
41 src="../../../../../../build/js/lp/app/extras/extras.js"></script>
42 <script type="text/javascript"
43 src="../../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script>
44 <script type="text/javascript"
45 src="../../../../../../build/js/lp/app/lp.js"></script>
46 <script type="text/javascript"
47 src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
48 <script type="text/javascript"
49 src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
50 <script type="text/javascript"
51 src="../../../../../../build/js/lp/app/ui/ui.js"></script>
52
53 <!-- The module under test. -->
54 <script type="text/javascript" src="../tokens.js"></script>
55
56 <!-- The test suite -->
57 <script type="text/javascript" src="test_tokens.js"></script>
58
59 <script id="fixture-template" type="text/x-template">
60 <div id="create-token">
61 <table class="form">
62 <tr>
63 <td>
64 <label for="field.description">Description:</label>
65 <input class="textType" id="field.description"
66 name="field.description" size="20" type="text" value="" />
67 </td>
68 <td>
69 <label for="field.scopes">Scopes:</label>
70 <label for="field.scopes.0">
71 <input class="checkboxType" id="field.scopes.0"
72 name="field.scopes" type="checkbox"
73 value="REPOSITORY_BUILD_STATUS"
74 />&nbsp;repository:build_status
75 </label>
76 <br />
77 <label for="field.scopes.1">
78 <input class="checkboxType" id="field.scopes.1"
79 name="field.scopes" type="checkbox"
80 value="REPOSITORY_PUSH" />&nbsp;repository:push
81 </label>
82 <input name="field.scopes.empty-marker" type="hidden"
83 value="1" />
84 </td>
85 <td>
86 <label for="field.date_expires">Expiry date:</label>
87 <input size="19" type="text" class="yui2-calendar withtime"
88 id="field.date_expires" name="field.date_expires" />
89 in time zone: UTC
90 </td>
91 </tr>
92 </table>
93
94 <p>
95 <input id="create-token-button" class="js-only"
96 type="button" value="Create token" />
97 <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
98 </p>
99 <div id="new-token-information" class="hidden">
100 <p>Your new personal access token is:</p>
101 <pre id="new-token-secret" class="subordinate"></pre>
102 <p>
103 Launchpad will not show you this again, so make sure to save it
104 now.
105 </p>
106 </div>
107 </div>
108
109 <table class="listing access-tokens-table">
110 <tbody id="access-tokens-tbody" />
111 </table>
112 </script>
113 </head>
114 <body class="yui3-skin-sam">
115 <ul id="suites">
116 <li>lp.services.auth.tokens.test</li>
117 </ul>
118
119 <div id="fixture" />
120 </body>
121</html>
diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.js b/lib/lp/services/auth/javascript/tests/test_tokens.js
0new file mode 100644122new file mode 100644
index 0000000..347b3d3
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.js
@@ -0,0 +1,181 @@
1/* Copyright 2021 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE). */
3
4YUI.add('lp.services.auth.tokens.test', function (Y) {
5
6 var tests = Y.namespace('lp.services.auth.tokens.test');
7 tests.suite = new Y.Test.Suite('lp.services.auth.tokens Tests');
8
9 tests.suite.add(new Y.Test.Case({
10 name: 'lp.services.auth.tokens_tests',
11
12 setUp: function () {
13 this.node = Y.Node.create(Y.one('#fixture-template').getContent());
14 Y.one('#fixture').append(this.node);
15 this.widget = new Y.lp.services.auth.tokens.CreateTokenWidget({
16 srcNode: Y.one('#create-token'),
17 target_uri: '/api/devel/repo'
18 });
19 this.mockio = new Y.lp.testing.mockio.MockIo();
20 this.widget.client.io_provider = this.mockio;
21 this.old_error_method = Y.lp.app.errors.display_error;
22 },
23
24 tearDown: function () {
25 this.widget.destroy();
26 Y.one('#fixture').empty();
27 Y.lp.app.errors.display_error = this.old_error_method;
28 },
29
30 test_library_exists: function () {
31 Y.Assert.isObject(Y.lp.services.auth.tokens,
32 'Could not locate the lp.services.auth.tokens module');
33 },
34
35 test_widget_can_be_instantiated: function () {
36 Y.Assert.isInstanceOf(
37 Y.lp.services.auth.tokens.CreateTokenWidget,
38 this.widget, 'Widget failed to be instantiated');
39 },
40
41 test_create_no_description: function () {
42 var error_shown = false;
43 Y.lp.app.errors.display_error = function(flash_node, msg) {
44 Y.Assert.areEqual(
45 Y.one('[name="field.description"]'), flash_node);
46 Y.Assert.areEqual(
47 'A personal access token must have a description.', msg);
48 error_shown = true;
49 };
50
51 this.widget.render();
52 Y.one('#create-token-button').simulate('click');
53 Y.Assert.isTrue(error_shown);
54 },
55
56 test_create_no_scopes: function () {
57 var error_shown = false;
58 Y.lp.app.errors.display_error = function(flash_node, msg) {
59 Y.Assert.isNull(flash_node);
60 Y.Assert.areEqual(
61 'A personal access token must have scopes.', msg);
62 error_shown = true;
63 };
64
65 Y.one('[name="field.description"]').set('value', 'Test');
66 this.widget.render();
67 Y.one('#create-token-button').simulate('click');
68 Y.Assert.isTrue(error_shown);
69 },
70
71 test_create_failure: function () {
72 var error_shown = false;
73 Y.lp.app.errors.display_error = function(flash_node, msg) {
74 Y.Assert.isNull(flash_node);
75 Y.Assert.areEqual(
76 'Failed to create personal access token.', msg);
77 error_shown = true;
78 };
79
80 Y.one('[name="field.description"]').set(
81 'value', 'Test description');
82 Y.all('[name="field.scopes"]').item(1).set('checked', true);
83 this.widget.render();
84 Y.one('#create-token-button').simulate('click');
85 Y.Assert.isFalse(error_shown);
86
87 this.mockio.failure();
88 Y.Assert.isTrue(error_shown);
89 },
90
91 test_create: function () {
92 var error_shown = false;
93 Y.lp.app.errors.display_error = function(flash_node, msg) {
94 error_shown = true;
95 };
96
97 Y.one('[name="field.description"]').set(
98 'value', 'Test description');
99 Y.all('[name="field.scopes"]').item(1).set('checked', true);
100 this.widget.render();
101 Y.one('#create-token-button').simulate('click');
102 Y.Assert.isFalse(error_shown);
103 Y.Assert.areEqual(1, this.mockio.requests.length);
104 Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
105 Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
106 Y.Assert.areEqual(
107 'ws.op=issueAccessToken' +
108 '&description=Test%20description' +
109 '&scopes=repository%3Apush',
110 this.mockio.last_request.config.data);
111 Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
112
113 this.mockio.success({
114 responseHeaders: {'Content-Type': 'application/json'},
115 responseText: Y.JSON.stringify('test-secret')
116 });
117 Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
118 Y.Assert.areEqual(
119 'test-secret', Y.one('#new-token-secret').get('text'));
120 Y.Assert.isFalse(
121 Y.one('#new-token-information').hasClass('hidden'));
122 var token_row = Y.one('#access-tokens-tbody tr');
123 Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
124 Y.ArrayAssert.itemsAreEqual(
125 ['Test description', 'repository:push', 'a moment ago',
126 '', 'Never', ''],
127 token_row.all('td').map(function (node) {
128 return node.get('text');
129 }));
130 },
131
132 test_create_with_expiry: function () {
133 var error_shown = false;
134 Y.lp.app.errors.display_error = function(flash_node, msg) {
135 error_shown = true;
136 };
137
138 Y.one('[name="field.description"]').set(
139 'value', 'Test description');
140 Y.all('[name="field.scopes"]').item(0).set('checked', true);
141 Y.one('[name="field.date_expires"]').set('value', '2021-01-01');
142 this.widget.render();
143 Y.one('#create-token-button').simulate('click');
144 Y.Assert.isFalse(error_shown);
145 Y.Assert.areEqual(1, this.mockio.requests.length);
146 Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
147 Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
148 Y.Assert.areEqual(
149 'ws.op=issueAccessToken' +
150 '&description=Test%20description' +
151 '&scopes=repository%3Abuild_status' +
152 '&date_expires=2021-01-01',
153 this.mockio.last_request.config.data);
154 Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
155
156 this.mockio.success({
157 responseHeaders: {'Content-Type': 'application/json'},
158 responseText: Y.JSON.stringify('test-secret')
159 });
160 Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
161 Y.Assert.areEqual(
162 'test-secret', Y.one('#new-token-secret').get('text'));
163 Y.Assert.isFalse(
164 Y.one('#new-token-information').hasClass('hidden'));
165 var token_row = Y.one('#access-tokens-tbody tr');
166 Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
167 Y.ArrayAssert.itemsAreEqual(
168 ['Test description', 'repository:build_status', 'a moment ago',
169 '', '2021-01-01', ''],
170 token_row.all('td').map(function (node) {
171 return node.get('text');
172 }));
173 }
174 }));
175
176}, '0.1', {
177 requires: [
178 'json-stringify', 'node-event-simulate', 'test',
179 'lp.services.auth.tokens', 'lp.testing.mockio'
180 ]
181});
diff --git a/lib/lp/services/auth/javascript/tokens.js b/lib/lp/services/auth/javascript/tokens.js
0new file mode 100644182new file mode 100644
index 0000000..81b4d4b
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tokens.js
@@ -0,0 +1,151 @@
1/* Copyright 2021 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * Personal access token widgets.
5 *
6 * @module lp.services.auth.tokens
7 * @requires node, widget, lp.app.errors, lp.client, lp.extras, lp.ui-base
8 */
9
10YUI.add('lp.services.auth.tokens', function(Y) {
11 var module = Y.namespace('lp.services.auth.tokens');
12
13 var CreateTokenWidget = function() {
14 CreateTokenWidget.superclass.constructor.apply(this, arguments);
15 };
16
17 CreateTokenWidget.NAME = 'create-token-widget';
18
19 CreateTokenWidget.ATTRS = {
20 /**
21 * The URI for the target for new tokens.
22 *
23 * @attribute target_uri
24 * @type String
25 * @default null
26 */
27 target_uri: {
28 value: null
29 }
30 };
31
32 Y.extend(CreateTokenWidget, Y.Widget, {
33 initializer: function(cfg) {
34 this.client = new Y.lp.client.Launchpad();
35 this.set('target_uri', cfg.target_uri);
36 },
37
38 /**
39 * Show the spinner.
40 *
41 * @method showSpinner
42 */
43 showSpinner: function() {
44 this.get('srcNode').all('.spinner').removeClass('hidden');
45 },
46
47 /**
48 * Hide the spinner.
49 *
50 * @method hideSpinner
51 */
52 hideSpinner: function() {
53 this.get('srcNode').all('.spinner').addClass('hidden');
54 },
55
56 /**
57 * Create a new token.
58 *
59 * @method createToken
60 */
61 createToken: function() {
62 var container = this.get('srcNode');
63 var description_node = container.one('[name="field.description"]');
64 var description = description_node.get('value');
65 if (description === '') {
66 Y.lp.app.errors.display_error(
67 description_node,
68 'A personal access token must have a description.');
69 return;
70 }
71 var scopes = container.all('[name="field.scopes"]')
72 .filter(':checked')
73 .map(function (node) {
74 var node_id = node.get('id');
75 var label = container.one('label[for="' + node_id + '"]');
76 return label.get('text').trim();
77 });
78 if (scopes.length === 0) {
79 Y.lp.app.errors.display_error(
80 null, 'A personal access token must have scopes.');
81 return;
82 }
83 var date_expires = container
84 .one('[name="field.date_expires"]').get('value');
85 var self = this;
86 var config = {
87 on: {
88 start: function() {
89 self.showSpinner();
90 },
91 end: function() {
92 self.hideSpinner();
93 },
94 success: function(response) {
95 container.one('#new-token-secret')
96 .set('text', response);
97 container.one('#new-token-information')
98 .removeClass('hidden');
99 var tokens_tbody = Y.one('#access-tokens-tbody');
100 tokens_tbody.append(Y.Node.create('<tr />')
101 .addClass(
102 tokens_tbody.all('tr').size() % 2
103 ? Y.lp.ui.CSS_ODD : Y.lp.ui.CSS_EVEN)
104 .append(Y.Node.create('<td />')
105 .set('text', description))
106 .append(Y.Node.create('<td />')
107 .set('text', scopes.join(', ')))
108 .append(Y.Node.create('<td />')
109 .set('text', 'a moment ago'))
110 .append(Y.Node.create('<td />'))
111 .append(Y.Node.create('<td />')
112 .set('text',
113 date_expires !== ''
114 ? date_expires : 'Never'))
115 .append(Y.Node.create('<td />')));
116 },
117 failure: function(ignore, response, args) {
118 Y.lp.app.errors.display_error(
119 null, 'Failed to create personal access token.');
120 }
121 },
122 parameters: {
123 description: description,
124 scopes: scopes
125 }
126 };
127 if (date_expires !== "") {
128 config.parameters.date_expires = date_expires;
129 }
130 this.client.named_post(
131 this.get('target_uri'), 'issueAccessToken', config);
132 },
133
134 bindUI: function() {
135 this.constructor.superclass.bindUI.call(this);
136 var create_token_button = this.get('srcNode')
137 .one('#create-token-button');
138 if (Y.Lang.isValue(create_token_button)) {
139 var self = this;
140 create_token_button.on('click', function(e) {
141 e.halt();
142 self.createToken();
143 });
144 }
145 }
146 });
147
148 module.CreateTokenWidget = CreateTokenWidget;
149}, '0.1', {'requires': [
150 'node', 'widget', 'lp.app.errors', 'lp.client', 'lp.extras', 'lp.ui-base'
151]});
diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
index 39a6684..b1cc7fc 100644
--- a/lib/lp/services/auth/model.py
+++ b/lib/lp/services/auth/model.py
@@ -186,7 +186,16 @@ class AccessTokenSet:
186 removeSecurityProxy(ids)._get_select())))186 removeSecurityProxy(ids)._get_select())))
187 else:187 else:
188 raise TypeError("Unsupported target: {!r}".format(target))188 raise TypeError("Unsupported target: {!r}".format(target))
189 return IStore(AccessToken).find(AccessToken, *clauses)189 clauses.append(Or(
190 AccessToken.date_expires == None,
191 AccessToken.date_expires > UTC_NOW))
192 return IStore(AccessToken).find(AccessToken, *clauses).order_by(
193 AccessToken.date_created)
194
195 def getByTargetAndID(self, target, token_id, visible_by_user=None):
196 """See `IAccessTokenSet`."""
197 return self.findByTarget(target, visible_by_user=visible_by_user).find(
198 id=token_id).one()
190199
191200
192class AccessTokenTargetMixin:201class AccessTokenTargetMixin:
diff --git a/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
193new file mode 100644202new file mode 100644
index 0000000..6d85df0
--- /dev/null
+++ b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
@@ -0,0 +1,122 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad"
8>
9<body>
10
11<metal:block fill-slot="head_epilogue">
12 <style type="text/css">
13 .js-only {
14 display: none;
15 }
16 .yui3-js-enabled .js-only {
17 display: inline;
18 }
19 </style>
20
21 <script type="text/javascript">
22 LPJS.use('node', 'lp.services.auth.tokens', function(Y) {
23 Y.on('domready', function() {
24 var ns = Y.lp.services.auth.tokens;
25 var create_token_widget = new ns.CreateTokenWidget({
26 srcNode: Y.one('#create-token'),
27 target_uri: LP.cache.context.self_link
28 });
29 create_token_widget.render();
30 });
31 });
32 </script>
33</metal:block>
34
35<div metal:fill-slot="main">
36 <p>Personal access tokens allow using certain parts of the Launchpad API.</p>
37
38 <h2>Create a token</h2>
39 <div id="create-token">
40 <metal:form use-macro="context/@@launchpad_form/form">
41 <metal:formbody fill-slot="widgets">
42 <table class="form">
43 <tal:widget define="widget nocall:view/widgets/description">
44 <metal:block use-macro="context/@@launchpad_form/widget_row" />
45 </tal:widget>
46 <tal:widget define="widget nocall:view/widgets/scopes">
47 <metal:block use-macro="context/@@launchpad_form/widget_row" />
48 </tal:widget>
49 <tal:widget define="widget nocall:view/widgets/date_expires">
50 <metal:block use-macro="context/@@launchpad_form/widget_row" />
51 </tal:widget>
52 </table>
53 </metal:formbody>
54
55 <metal:buttons fill-slot="buttons">
56 <p>
57 <input id="create-token-button" class="js-only"
58 type="button" value="Create token" />
59 <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
60 <noscript><strong>
61 Creating personal access tokens requires JavaScript.
62 </strong></noscript>
63 </p>
64 <div id="new-token-information" class="hidden">
65 <p>Your new personal access token is:</p>
66 <pre id="new-token-secret" class="subordinate"></pre>
67 <p>
68 Launchpad will not show you this again, so make sure to save it
69 now.
70 </p>
71 </div>
72 </metal:buttons>
73 </metal:form>
74 </div>
75
76 <h2>Active tokens</h2>
77 <table class="listing access-tokens-table"
78 style="max-width: 80em;">
79 <thead>
80 <tr>
81 <th>Description</th>
82 <th>Scopes</th>
83 <th>Created</th>
84 <th>Last used</th>
85 <th>Expires</th>
86 <th><tal:comment condition="nothing">Revoke button</tal:comment></th>
87 </tr>
88 </thead>
89 <tbody id="access-tokens-tbody">
90 <tr tal:repeat="token view/access_tokens"
91 tal:attributes="
92 class python: 'yui3-lazr-even' if repeat['token'].even()
93 else 'yui3-lazr-odd';
94 token-id token/id">
95 <td tal:content="token/description" />
96 <td tal:content="
97 python: ', '.join(scope.title for scope in token.scopes)" />
98 <td tal:content="
99 structure token/date_created/fmt:approximatedatetitle" />
100 <td tal:content="
101 structure token/date_last_used/fmt:approximatedatetitle" />
102 <td>
103 <tal:expires
104 condition="token/date_expires"
105 replace="
106 structure token/date_expires/fmt:approximatedatetitle" />
107 <span tal:condition="not: token/date_expires">Never</span>
108 </td>
109 <td>
110 <form method="post" tal:attributes="name string:revoke-${token/id}">
111 <input type="hidden" name="token_id"
112 tal:attributes="value token/id" />
113 <input type="submit" name="field.actions.revoke" value="Revoke" />
114 </form>
115 </td>
116 </tr>
117 </tbody>
118 </table>
119</div>
120
121</body>
122</html>
diff --git a/lib/lp/services/auth/tests/test_browser.py b/lib/lp/services/auth/tests/test_browser.py
0new file mode 100644123new file mode 100644
index 0000000..6ec0b70
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_browser.py
@@ -0,0 +1,151 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test personal access token views."""
5
6import re
7
8import soupmatchers
9from testtools.matchers import (
10 Equals,
11 Is,
12 MatchesAll,
13 MatchesListwise,
14 MatchesStructure,
15 Not,
16 )
17from zope.component import getUtility
18from zope.security.proxy import removeSecurityProxy
19
20from lp.services.webapp.interfaces import IPlacelessAuthUtility
21from lp.services.webapp.publisher import canonical_url
22from lp.testing import (
23 login_person,
24 TestCaseWithFactory,
25 )
26from lp.testing.layers import DatabaseFunctionalLayer
27from lp.testing.views import create_view
28
29
30breadcrumbs_tag = soupmatchers.Tag(
31 "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
32tokens_page_crumb_tag = soupmatchers.Tag(
33 "tokens page breadcrumb", "li", text=re.compile(r"Personal access tokens"))
34token_listing_constants = soupmatchers.HTMLContains(
35 soupmatchers.Within(breadcrumbs_tag, tokens_page_crumb_tag))
36token_listing_tag = soupmatchers.Tag(
37 "tokens table", "table", attrs={"class": "listing"})
38
39
40class TestAccessTokenViewBase:
41
42 layer = DatabaseFunctionalLayer
43
44 def setUp(self):
45 super().setUp()
46 self.target = self.makeTarget()
47 self.owner = self.target.owner
48 login_person(self.owner)
49
50 def makeView(self, name, **kwargs):
51 # XXX cjwatson 2021-10-19: We need to give the view a
52 # LaunchpadPrincipal rather than just a person, since otherwise bits
53 # of the navigation menu machinery try to use the scope_url
54 # attribute on the principal and fail. This should probably be done
55 # in create_view instead, but that approach needs care to avoid
56 # adding an extra query to tests that might be sensitive to that.
57 principal = getUtility(IPlacelessAuthUtility).getPrincipal(
58 self.owner.accountID)
59 view = create_view(
60 self.target, name, principal=principal, current_request=True,
61 **kwargs)
62 # To test the breadcrumbs we need a correct traversal stack.
63 view.request.traversed_objects = (
64 self.getTraversalStack(self.target) + [view])
65 # The navigation menu machinery needs this to find the view from the
66 # request.
67 view.request._last_obj_traversed = view
68 view.initialize()
69 return view
70
71 def makeTokensAndMatchers(self, count):
72 tokens = [
73 self.factory.makeAccessToken(target=self.target)[1]
74 for _ in range(count)]
75 # There is a row for each token.
76 matchers = []
77 for token in tokens:
78 row_tag = soupmatchers.Tag(
79 "token row", "tr",
80 attrs={"token-id": removeSecurityProxy(token).id})
81 column_tags = [
82 soupmatchers.Tag(
83 "description", "td", text=token.description),
84 soupmatchers.Tag(
85 "scopes", "td",
86 text=", ".join(scope.title for scope in token.scopes)),
87 ]
88 matchers.extend([
89 soupmatchers.Within(row_tag, column_tag)
90 for column_tag in column_tags])
91 return matchers
92
93 def test_empty(self):
94 self.assertThat(
95 self.makeView("+access-tokens")(),
96 MatchesAll(
97 token_listing_constants,
98 soupmatchers.HTMLContains(token_listing_tag)))
99
100 def test_existing_tokens(self):
101 token_matchers = self.makeTokensAndMatchers(10)
102 self.assertThat(
103 self.makeView("+access-tokens")(),
104 MatchesAll(
105 token_listing_constants,
106 soupmatchers.HTMLContains(token_listing_tag, *token_matchers)))
107
108 def test_revoke(self):
109 tokens = [
110 self.factory.makeAccessToken(target=self.target)[1]
111 for _ in range(3)]
112 token_ids = [token.id for token in tokens]
113 access_tokens_url = canonical_url(
114 self.target, view_name="+access-tokens")
115 browser = self.getUserBrowser(access_tokens_url, user=self.owner)
116 for token_id in token_ids:
117 self.assertThat(
118 browser.getForm(name="revoke-%s" % token_id).controls,
119 MatchesListwise([
120 MatchesStructure.byEquality(
121 type="hidden", name="token_id", value=str(token_id)),
122 MatchesStructure.byEquality(
123 type="submit", name="field.actions.revoke",
124 value="Revoke"),
125 ]))
126 browser.getForm(name="revoke-%s" % token_ids[1]).getControl(
127 "Revoke").click()
128 login_person(self.owner)
129 self.assertEqual(access_tokens_url, browser.url)
130 self.assertThat(tokens[0], MatchesStructure(
131 id=Equals(token_ids[0]),
132 date_expires=Is(None),
133 revoked_by=Is(None)))
134 self.assertThat(tokens[1], MatchesStructure(
135 id=Equals(token_ids[1]),
136 date_expires=Not(Is(None)),
137 revoked_by=Equals(self.owner)))
138 self.assertThat(tokens[2], MatchesStructure(
139 id=Equals(token_ids[2]),
140 date_expires=Is(None),
141 revoked_by=Is(None)))
142
143
144class TestAccessTokenViewGitRepository(
145 TestAccessTokenViewBase, TestCaseWithFactory):
146
147 def makeTarget(self):
148 return self.factory.makeGitRepository()
149
150 def getTraversalStack(self, obj):
151 return [obj.target, obj]
diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
index c64dbd9..805b02c 100644
--- a/lib/lp/services/auth/tests/test_model.py
+++ b/lib/lp/services/auth/tests/test_model.py
@@ -251,6 +251,87 @@ class TestAccessTokenSet(TestCaseWithFactory):
251 targets[target_index],251 targets[target_index],
252 visible_by_user=owners[owner_index]))252 visible_by_user=owners[owner_index]))
253253
254 def test_findByTarget_excludes_expired(self):
255 target = self.factory.makeGitRepository()
256 _, current_token = self.factory.makeAccessToken(target=target)
257 _, expires_soon_token = self.factory.makeAccessToken(
258 target=target,
259 date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
260 _, expired_token = self.factory.makeAccessToken(
261 target=target,
262 date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
263 self.assertContentEqual(
264 [current_token, expires_soon_token],
265 getUtility(IAccessTokenSet).findByTarget(target))
266
267 def test_getByTargetAndID(self):
268 targets = [self.factory.makeGitRepository() for _ in range(3)]
269 tokens = [
270 self.factory.makeAccessToken(target=targets[0])[1],
271 self.factory.makeAccessToken(target=targets[0])[1],
272 self.factory.makeAccessToken(target=targets[1])[1],
273 ]
274 self.assertEqual(
275 tokens[0],
276 getUtility(IAccessTokenSet).getByTargetAndID(
277 targets[0], removeSecurityProxy(tokens[0]).id))
278 self.assertEqual(
279 tokens[1],
280 getUtility(IAccessTokenSet).getByTargetAndID(
281 targets[0], removeSecurityProxy(tokens[1]).id))
282 self.assertIsNone(
283 getUtility(IAccessTokenSet).getByTargetAndID(
284 targets[0], removeSecurityProxy(tokens[2]).id))
285
286 def test_getByTargetAndID_visible_by_user(self):
287 targets = [self.factory.makeGitRepository() for _ in range(3)]
288 owners = [self.factory.makePerson() for _ in range(3)]
289 tokens = [
290 self.factory.makeAccessToken(
291 owner=owners[owner_index], target=targets[target_index])[1]
292 for owner_index, target_index in (
293 (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
294 for owner_index, target_index, expected_tokens in (
295 (0, 0, tokens[:2]),
296 (0, 1, []),
297 (0, 2, []),
298 (1, 0, [tokens[2]]),
299 (1, 1, [tokens[3]]),
300 (1, 2, []),
301 (2, 0, []),
302 (2, 1, [tokens[4]]),
303 (2, 2, []),
304 ):
305 for token in tokens:
306 fetched_token = getUtility(IAccessTokenSet).getByTargetAndID(
307 targets[target_index], removeSecurityProxy(token).id,
308 visible_by_user=owners[owner_index])
309 if token in expected_tokens:
310 self.assertEqual(token, fetched_token)
311 else:
312 self.assertIsNone(fetched_token)
313
314 def test_getByTargetAndID_excludes_expired(self):
315 target = self.factory.makeGitRepository()
316 _, current_token = self.factory.makeAccessToken(target=target)
317 _, expires_soon_token = self.factory.makeAccessToken(
318 target=target,
319 date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
320 _, expired_token = self.factory.makeAccessToken(
321 target=target,
322 date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
323 self.assertEqual(
324 current_token,
325 getUtility(IAccessTokenSet).getByTargetAndID(
326 target, removeSecurityProxy(current_token).id))
327 self.assertEqual(
328 expires_soon_token,
329 getUtility(IAccessTokenSet).getByTargetAndID(
330 target, removeSecurityProxy(expires_soon_token).id))
331 self.assertIsNone(
332 getUtility(IAccessTokenSet).getByTargetAndID(
333 target, removeSecurityProxy(expired_token).id))
334
254335
255class TestAccessTokenTargetBase:336class TestAccessTokenTargetBase:
256337
diff --git a/lib/lp/services/auth/tests/test_yuitests.py b/lib/lp/services/auth/tests/test_yuitests.py
257new file mode 100644338new file mode 100644
index 0000000..069cc9d
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_yuitests.py
@@ -0,0 +1,23 @@
1# Copyright 2021 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Run YUI.test tests."""
5
6__all__ = []
7
8from lp.testing import (
9 build_yui_unittest_suite,
10 YUIUnitTestCase,
11 )
12from lp.testing.layers import YUITestLayer
13
14
15class AuthYUIUnitTestCase(YUIUnitTestCase):
16
17 layer = YUITestLayer
18 suite_name = "AuthYUIUnitTests"
19
20
21def test_suite():
22 app_testing_path = "lp/services/auth"
23 return build_yui_unittest_suite(app_testing_path, AuthYUIUnitTestCase)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 9b500b4..2856fa0 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4538,6 +4538,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
4538 token = getUtility(IAccessTokenSet).new(4538 token = getUtility(IAccessTokenSet).new(
4539 secret, owner, description, target, scopes,4539 secret, owner, description, target, scopes,
4540 date_expires=date_expires)4540 date_expires=date_expires)
4541 IStore(token).flush()
4541 return secret, token4542 return secret, token
45424543
4543 def makeCVE(self, sequence, description=None,4544 def makeCVE(self, sequence, description=None,

Subscribers

People subscribed via source and target branches

to status/vote changes: