launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27615
[Merge] ~cjwatson/launchpad:access-token-ui into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:access-token-ui into launchpad:master.
Commit message:
Add UI for personal access tokens
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/410560
This requires JavaScript in order to ensure that the new token secret is never stored in the database.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:access-token-ui into launchpad:master.
diff --git a/lib/lp/services/auth/browser.py b/lib/lp/services/auth/browser.py
new file mode 100644
index 0000000..5c9a35d
--- /dev/null
+++ b/lib/lp/services/auth/browser.py
@@ -0,0 +1,80 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""UI for personal access tokens."""
+
+__all__ = [
+ "AccessTokensView",
+ ]
+
+from lazr.restful.interface import (
+ copy_field,
+ use_template,
+ )
+from zope.component import getUtility
+from zope.interface import Interface
+
+from lp import _
+from lp.app.browser.launchpadform import (
+ action,
+ LaunchpadFormView,
+ )
+from lp.app.errors import UnexpectedFormData
+from lp.app.widgets.date import DateTimeWidget
+from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.services.auth.interfaces import (
+ IAccessToken,
+ IAccessTokenSet,
+ )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp.publisher import canonical_url
+
+
+class IAccessTokenCreateSchema(Interface):
+ """Schema for creating a personal access token."""
+
+ use_template(IAccessToken, include=[
+ "description",
+ "scopes",
+ ])
+
+ date_expires = copy_field(
+ IAccessToken["date_expires"],
+ description=_("When the token should expire."))
+
+
+class AccessTokensView(LaunchpadFormView):
+
+ schema = IAccessTokenCreateSchema
+ custom_widget_scopes = LabeledMultiCheckBoxWidget
+ custom_widget_date_expires = DateTimeWidget
+
+ @property
+ def label(self):
+ return "Personal access tokens for %s" % self.context.display_name
+
+ page_title = "Personal access tokens"
+
+ @cachedproperty
+ def access_tokens(self):
+ return list(getUtility(IAccessTokenSet).findByTarget(
+ self.context, visible_by_user=self.user))
+
+ @action("Revoke", name="revoke")
+ def revoke_action(self, action, data):
+ form = self.request.form
+ token_id = form.get("token_id")
+ if token_id is None:
+ raise UnexpectedFormData("Missing token_id")
+ try:
+ token_id = int(token_id)
+ except ValueError:
+ raise UnexpectedFormData("token_id is not an integer")
+ token = getUtility(IAccessTokenSet).getByTargetAndID(
+ self.context, token_id, visible_by_user=self.user)
+ if token is not None:
+ token.revoke(self.user)
+ self.request.response.addInfoNotification(
+ "Token revoked successfully.")
+ self.request.response.redirect(
+ 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 @@
path_expression="string:+access-token/${id}"
attribute_to_parent="target" />
+ <browser:page
+ for="lp.services.auth.interfaces.IAccessTokenTarget"
+ name="+access-tokens"
+ permission="launchpad.Edit"
+ class="lp.services.auth.browser.AccessTokensView"
+ template="templates/accesstokentarget-access-tokens.pt" />
+
<webservice:register module="lp.services.auth.webservice" />
</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):
id = Int(title=_("ID"), required=True, readonly=True)
date_created = exported(Datetime(
- title=_("When the token was created."), required=True, readonly=True))
+ title=_("Creation date"),
+ description=_("When the token was created."),
+ required=True, readonly=True))
owner = exported(PublicPersonChoice(
- title=_("The person who created the token."),
+ title=_("Owner"),
+ description=_("The person who created the token."),
vocabulary="ValidPersonOrTeam", required=True, readonly=True))
description = exported(TextLine(
- title=_("A short description of the token."), required=True))
+ title=_("Description"),
+ description=_("A short description of the token."), required=True))
git_repository = Reference(
- title=_("The Git repository for which the token was issued."),
+ title=_("Git repository"),
+ description=_("The Git repository for which the token was issued."),
# Really IGitRepository, patched in _schema_circular_imports.py.
schema=Interface, required=True, readonly=True)
target = exported(Reference(
- title=_("The target for which the token was issued."),
+ title=_("Target"),
+ description=_("The target for which the token was issued."),
# Really IAccessTokenTarget, patched in _schema_circular_imports.py.
schema=Interface, required=True, readonly=True))
scopes = exported(List(
value_type=Choice(vocabulary=AccessTokenScope),
- title=_("A list of scopes granted by the token."),
+ title=_("Scopes"),
+ description=_("A list of scopes granted by the token."),
required=True, readonly=True))
date_last_used = exported(Datetime(
- title=_("When the token was last used."),
+ title=_("Date last used"),
+ description=_("When the token was last used."),
required=False, readonly=True))
date_expires = exported(Datetime(
- title=_("When the token should expire or was revoked."),
+ title=_("Expiry date"),
+ description=_("When the token should expire or was revoked."),
required=False, readonly=True))
is_expired = Bool(
- title=_("Whether this token has expired."),
+ description=_("Whether this token has expired."),
required=False, readonly=True)
revoked_by = exported(PublicPersonChoice(
- title=_("The person who revoked the token, if any."),
+ title=_("Revoked by"),
+ description=_("The person who revoked the token, if any."),
vocabulary="ValidPersonOrTeam", required=False, readonly=True))
def updateLastUsed():
@@ -135,6 +145,15 @@ class IAccessTokenSet(Interface):
by this user.
"""
+ def getByTargetAndID(target, token_id, visible_by_user=None):
+ """Return the access token with this target and ID, or None.
+
+ :param target: An `IAccessTokenTarget`.
+ :param token_id: An `AccessToken` ID.
+ :param visible_by_user: If given, return only access tokens visible
+ by this user.
+ """
+
class IAccessTokenVerifiedRequest(Interface):
"""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
new file mode 100644
index 0000000..f7887fb
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+Copyright 2021 Canonical Ltd. This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+ <head>
+ <title>Personal access token widget tests</title>
+
+ <!-- YUI and test setup -->
+ <script type="text/javascript"
+ src="../../../../../../build/js/yui/yui/yui.js">
+ </script>
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+ <link rel="stylesheet"
+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/testing/helpers.js"></script>
+
+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+ <!-- Dependencies -->
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/client.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/effects/effects.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/errors.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/expander.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/lp.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
+ <script type="text/javascript"
+ src="../../../../../../build/js/lp/app/ui/ui.js"></script>
+
+ <!-- The module under test. -->
+ <script type="text/javascript" src="../tokens.js"></script>
+
+ <!-- The test suite -->
+ <script type="text/javascript" src="test_tokens.js"></script>
+
+ <script id="fixture-template" type="text/x-template">
+ <div id="create-token">
+ <table class="form">
+ <tr>
+ <td>
+ <label for="field.description">Description:</label>
+ <input class="textType" id="field.description"
+ name="field.description" size="20" type="text" value="" />
+ </td>
+ <td>
+ <label for="field.scopes">Scopes:</label>
+ <label for="field.scopes.0">
+ <input class="checkboxType" id="field.scopes.0"
+ name="field.scopes" type="checkbox"
+ value="REPOSITORY_BUILD_STATUS"
+ /> repository:build_status
+ </label>
+ <br />
+ <label for="field.scopes.1">
+ <input class="checkboxType" id="field.scopes.1"
+ name="field.scopes" type="checkbox"
+ value="REPOSITORY_PUSH" /> repository:push
+ </label>
+ <input name="field.scopes.empty-marker" type="hidden"
+ value="1" />
+ </td>
+ <td>
+ <label for="field.date_expires">Expiry date:</label>
+ <input size="19" type="text" class="yui2-calendar withtime"
+ id="field.date_expires" name="field.date_expires" />
+ in time zone: UTC
+ </td>
+ </tr>
+ </table>
+
+ <p>
+ <input id="create-token-button" class="js-only"
+ type="button" value="Create token" />
+ <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
+ </p>
+ <div id="new-token-information" class="hidden">
+ <p>Your new personal access token is:</p>
+ <pre id="new-token-secret" class="subordinate"></pre>
+ <p>
+ Launchpad will not show you this again, so make sure to save it
+ now.
+ </p>
+ </div>
+ </div>
+
+ <table class="listing access-tokens-table">
+ <tbody id="access-tokens-tbody" />
+ </table>
+ </script>
+ </head>
+ <body class="yui3-skin-sam">
+ <ul id="suites">
+ <li>lp.services.auth.tokens.test</li>
+ </ul>
+
+ <div id="fixture" />
+ </body>
+</html>
diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.js b/lib/lp/services/auth/javascript/tests/test_tokens.js
new file mode 100644
index 0000000..347b3d3
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.js
@@ -0,0 +1,181 @@
+/* Copyright 2021 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE). */
+
+YUI.add('lp.services.auth.tokens.test', function (Y) {
+
+ var tests = Y.namespace('lp.services.auth.tokens.test');
+ tests.suite = new Y.Test.Suite('lp.services.auth.tokens Tests');
+
+ tests.suite.add(new Y.Test.Case({
+ name: 'lp.services.auth.tokens_tests',
+
+ setUp: function () {
+ this.node = Y.Node.create(Y.one('#fixture-template').getContent());
+ Y.one('#fixture').append(this.node);
+ this.widget = new Y.lp.services.auth.tokens.CreateTokenWidget({
+ srcNode: Y.one('#create-token'),
+ target_uri: '/api/devel/repo'
+ });
+ this.mockio = new Y.lp.testing.mockio.MockIo();
+ this.widget.client.io_provider = this.mockio;
+ this.old_error_method = Y.lp.app.errors.display_error;
+ },
+
+ tearDown: function () {
+ this.widget.destroy();
+ Y.one('#fixture').empty();
+ Y.lp.app.errors.display_error = this.old_error_method;
+ },
+
+ test_library_exists: function () {
+ Y.Assert.isObject(Y.lp.services.auth.tokens,
+ 'Could not locate the lp.services.auth.tokens module');
+ },
+
+ test_widget_can_be_instantiated: function () {
+ Y.Assert.isInstanceOf(
+ Y.lp.services.auth.tokens.CreateTokenWidget,
+ this.widget, 'Widget failed to be instantiated');
+ },
+
+ test_create_no_description: function () {
+ var error_shown = false;
+ Y.lp.app.errors.display_error = function(flash_node, msg) {
+ Y.Assert.areEqual(
+ Y.one('[name="field.description"]'), flash_node);
+ Y.Assert.areEqual(
+ 'A personal access token must have a description.', msg);
+ error_shown = true;
+ };
+
+ this.widget.render();
+ Y.one('#create-token-button').simulate('click');
+ Y.Assert.isTrue(error_shown);
+ },
+
+ test_create_no_scopes: function () {
+ var error_shown = false;
+ Y.lp.app.errors.display_error = function(flash_node, msg) {
+ Y.Assert.isNull(flash_node);
+ Y.Assert.areEqual(
+ 'A personal access token must have scopes.', msg);
+ error_shown = true;
+ };
+
+ Y.one('[name="field.description"]').set('value', 'Test');
+ this.widget.render();
+ Y.one('#create-token-button').simulate('click');
+ Y.Assert.isTrue(error_shown);
+ },
+
+ test_create_failure: function () {
+ var error_shown = false;
+ Y.lp.app.errors.display_error = function(flash_node, msg) {
+ Y.Assert.isNull(flash_node);
+ Y.Assert.areEqual(
+ 'Failed to create personal access token.', msg);
+ error_shown = true;
+ };
+
+ Y.one('[name="field.description"]').set(
+ 'value', 'Test description');
+ Y.all('[name="field.scopes"]').item(1).set('checked', true);
+ this.widget.render();
+ Y.one('#create-token-button').simulate('click');
+ Y.Assert.isFalse(error_shown);
+
+ this.mockio.failure();
+ Y.Assert.isTrue(error_shown);
+ },
+
+ test_create: function () {
+ var error_shown = false;
+ Y.lp.app.errors.display_error = function(flash_node, msg) {
+ error_shown = true;
+ };
+
+ Y.one('[name="field.description"]').set(
+ 'value', 'Test description');
+ Y.all('[name="field.scopes"]').item(1).set('checked', true);
+ this.widget.render();
+ Y.one('#create-token-button').simulate('click');
+ Y.Assert.isFalse(error_shown);
+ Y.Assert.areEqual(1, this.mockio.requests.length);
+ Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
+ Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
+ Y.Assert.areEqual(
+ 'ws.op=issueAccessToken' +
+ '&description=Test%20description' +
+ '&scopes=repository%3Apush',
+ this.mockio.last_request.config.data);
+ Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
+
+ this.mockio.success({
+ responseHeaders: {'Content-Type': 'application/json'},
+ responseText: Y.JSON.stringify('test-secret')
+ });
+ Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
+ Y.Assert.areEqual(
+ 'test-secret', Y.one('#new-token-secret').get('text'));
+ Y.Assert.isFalse(
+ Y.one('#new-token-information').hasClass('hidden'));
+ var token_row = Y.one('#access-tokens-tbody tr');
+ Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
+ Y.ArrayAssert.itemsAreEqual(
+ ['Test description', 'repository:push', 'a moment ago',
+ '', 'Never', ''],
+ token_row.all('td').map(function (node) {
+ return node.get('text');
+ }));
+ },
+
+ test_create_with_expiry: function () {
+ var error_shown = false;
+ Y.lp.app.errors.display_error = function(flash_node, msg) {
+ error_shown = true;
+ };
+
+ Y.one('[name="field.description"]').set(
+ 'value', 'Test description');
+ Y.all('[name="field.scopes"]').item(0).set('checked', true);
+ Y.one('[name="field.date_expires"]').set('value', '2021-01-01');
+ this.widget.render();
+ Y.one('#create-token-button').simulate('click');
+ Y.Assert.isFalse(error_shown);
+ Y.Assert.areEqual(1, this.mockio.requests.length);
+ Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
+ Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
+ Y.Assert.areEqual(
+ 'ws.op=issueAccessToken' +
+ '&description=Test%20description' +
+ '&scopes=repository%3Abuild_status' +
+ '&date_expires=2021-01-01',
+ this.mockio.last_request.config.data);
+ Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
+
+ this.mockio.success({
+ responseHeaders: {'Content-Type': 'application/json'},
+ responseText: Y.JSON.stringify('test-secret')
+ });
+ Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
+ Y.Assert.areEqual(
+ 'test-secret', Y.one('#new-token-secret').get('text'));
+ Y.Assert.isFalse(
+ Y.one('#new-token-information').hasClass('hidden'));
+ var token_row = Y.one('#access-tokens-tbody tr');
+ Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
+ Y.ArrayAssert.itemsAreEqual(
+ ['Test description', 'repository:build_status', 'a moment ago',
+ '', '2021-01-01', ''],
+ token_row.all('td').map(function (node) {
+ return node.get('text');
+ }));
+ }
+ }));
+
+}, '0.1', {
+ requires: [
+ 'json-stringify', 'node-event-simulate', 'test',
+ 'lp.services.auth.tokens', 'lp.testing.mockio'
+ ]
+});
diff --git a/lib/lp/services/auth/javascript/tokens.js b/lib/lp/services/auth/javascript/tokens.js
new file mode 100644
index 0000000..81b4d4b
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tokens.js
@@ -0,0 +1,151 @@
+/* Copyright 2021 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Personal access token widgets.
+ *
+ * @module lp.services.auth.tokens
+ * @requires node, widget, lp.app.errors, lp.client, lp.extras, lp.ui-base
+ */
+
+YUI.add('lp.services.auth.tokens', function(Y) {
+ var module = Y.namespace('lp.services.auth.tokens');
+
+ var CreateTokenWidget = function() {
+ CreateTokenWidget.superclass.constructor.apply(this, arguments);
+ };
+
+ CreateTokenWidget.NAME = 'create-token-widget';
+
+ CreateTokenWidget.ATTRS = {
+ /**
+ * The URI for the target for new tokens.
+ *
+ * @attribute target_uri
+ * @type String
+ * @default null
+ */
+ target_uri: {
+ value: null
+ }
+ };
+
+ Y.extend(CreateTokenWidget, Y.Widget, {
+ initializer: function(cfg) {
+ this.client = new Y.lp.client.Launchpad();
+ this.set('target_uri', cfg.target_uri);
+ },
+
+ /**
+ * Show the spinner.
+ *
+ * @method showSpinner
+ */
+ showSpinner: function() {
+ this.get('srcNode').all('.spinner').removeClass('hidden');
+ },
+
+ /**
+ * Hide the spinner.
+ *
+ * @method hideSpinner
+ */
+ hideSpinner: function() {
+ this.get('srcNode').all('.spinner').addClass('hidden');
+ },
+
+ /**
+ * Create a new token.
+ *
+ * @method createToken
+ */
+ createToken: function() {
+ var container = this.get('srcNode');
+ var description_node = container.one('[name="field.description"]');
+ var description = description_node.get('value');
+ if (description === '') {
+ Y.lp.app.errors.display_error(
+ description_node,
+ 'A personal access token must have a description.');
+ return;
+ }
+ var scopes = container.all('[name="field.scopes"]')
+ .filter(':checked')
+ .map(function (node) {
+ var node_id = node.get('id');
+ var label = container.one('label[for="' + node_id + '"]');
+ return label.get('text').trim();
+ });
+ if (scopes.length === 0) {
+ Y.lp.app.errors.display_error(
+ null, 'A personal access token must have scopes.');
+ return;
+ }
+ var date_expires = container
+ .one('[name="field.date_expires"]').get('value');
+ var self = this;
+ var config = {
+ on: {
+ start: function() {
+ self.showSpinner();
+ },
+ end: function() {
+ self.hideSpinner();
+ },
+ success: function(response) {
+ container.one('#new-token-secret')
+ .set('text', response);
+ container.one('#new-token-information')
+ .removeClass('hidden');
+ var tokens_tbody = Y.one('#access-tokens-tbody');
+ tokens_tbody.append(Y.Node.create('<tr />')
+ .addClass(
+ tokens_tbody.all('tr').size() % 2
+ ? Y.lp.ui.CSS_ODD : Y.lp.ui.CSS_EVEN)
+ .append(Y.Node.create('<td />')
+ .set('text', description))
+ .append(Y.Node.create('<td />')
+ .set('text', scopes.join(', ')))
+ .append(Y.Node.create('<td />')
+ .set('text', 'a moment ago'))
+ .append(Y.Node.create('<td />'))
+ .append(Y.Node.create('<td />')
+ .set('text',
+ date_expires !== ''
+ ? date_expires : 'Never'))
+ .append(Y.Node.create('<td />')));
+ },
+ failure: function(ignore, response, args) {
+ Y.lp.app.errors.display_error(
+ null, 'Failed to create personal access token.');
+ }
+ },
+ parameters: {
+ description: description,
+ scopes: scopes
+ }
+ };
+ if (date_expires !== "") {
+ config.parameters.date_expires = date_expires;
+ }
+ this.client.named_post(
+ this.get('target_uri'), 'issueAccessToken', config);
+ },
+
+ bindUI: function() {
+ this.constructor.superclass.bindUI.call(this);
+ var create_token_button = this.get('srcNode')
+ .one('#create-token-button');
+ if (Y.Lang.isValue(create_token_button)) {
+ var self = this;
+ create_token_button.on('click', function(e) {
+ e.halt();
+ self.createToken();
+ });
+ }
+ }
+ });
+
+ module.CreateTokenWidget = CreateTokenWidget;
+}, '0.1', {'requires': [
+ 'node', 'widget', 'lp.app.errors', 'lp.client', 'lp.extras', 'lp.ui-base'
+]});
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:
removeSecurityProxy(ids)._get_select())))
else:
raise TypeError("Unsupported target: {!r}".format(target))
- return IStore(AccessToken).find(AccessToken, *clauses)
+ clauses.append(Or(
+ AccessToken.date_expires == None,
+ AccessToken.date_expires > UTC_NOW))
+ return IStore(AccessToken).find(AccessToken, *clauses).order_by(
+ AccessToken.date_created)
+
+ def getByTargetAndID(self, target, token_id, visible_by_user=None):
+ """See `IAccessTokenSet`."""
+ return self.findByTarget(target, visible_by_user=visible_by_user).find(
+ id=token_id).one()
class AccessTokenTargetMixin:
diff --git a/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
new file mode 100644
index 0000000..6d85df0
--- /dev/null
+++ b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
@@ -0,0 +1,122 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad"
+>
+<body>
+
+<metal:block fill-slot="head_epilogue">
+ <style type="text/css">
+ .js-only {
+ display: none;
+ }
+ .yui3-js-enabled .js-only {
+ display: inline;
+ }
+ </style>
+
+ <script type="text/javascript">
+ LPJS.use('node', 'lp.services.auth.tokens', function(Y) {
+ Y.on('domready', function() {
+ var ns = Y.lp.services.auth.tokens;
+ var create_token_widget = new ns.CreateTokenWidget({
+ srcNode: Y.one('#create-token'),
+ target_uri: LP.cache.context.self_link
+ });
+ create_token_widget.render();
+ });
+ });
+ </script>
+</metal:block>
+
+<div metal:fill-slot="main">
+ <p>Personal access tokens allow using certain parts of the Launchpad API.</p>
+
+ <h2>Create a token</h2>
+ <div id="create-token">
+ <metal:form use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+ <table class="form">
+ <tal:widget define="widget nocall:view/widgets/description">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/scopes">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/date_expires">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </table>
+ </metal:formbody>
+
+ <metal:buttons fill-slot="buttons">
+ <p>
+ <input id="create-token-button" class="js-only"
+ type="button" value="Create token" />
+ <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
+ <noscript><strong>
+ Creating personal access tokens requires JavaScript.
+ </strong></noscript>
+ </p>
+ <div id="new-token-information" class="hidden">
+ <p>Your new personal access token is:</p>
+ <pre id="new-token-secret" class="subordinate"></pre>
+ <p>
+ Launchpad will not show you this again, so make sure to save it
+ now.
+ </p>
+ </div>
+ </metal:buttons>
+ </metal:form>
+ </div>
+
+ <h2>Active tokens</h2>
+ <table class="listing access-tokens-table"
+ style="max-width: 80em;">
+ <thead>
+ <tr>
+ <th>Description</th>
+ <th>Scopes</th>
+ <th>Created</th>
+ <th>Last used</th>
+ <th>Expires</th>
+ <th><tal:comment condition="nothing">Revoke button</tal:comment></th>
+ </tr>
+ </thead>
+ <tbody id="access-tokens-tbody">
+ <tr tal:repeat="token view/access_tokens"
+ tal:attributes="
+ class python: 'yui3-lazr-even' if repeat['token'].even()
+ else 'yui3-lazr-odd';
+ token-id token/id">
+ <td tal:content="token/description" />
+ <td tal:content="
+ python: ', '.join(scope.title for scope in token.scopes)" />
+ <td tal:content="
+ structure token/date_created/fmt:approximatedatetitle" />
+ <td tal:content="
+ structure token/date_last_used/fmt:approximatedatetitle" />
+ <td>
+ <tal:expires
+ condition="token/date_expires"
+ replace="
+ structure token/date_expires/fmt:approximatedatetitle" />
+ <span tal:condition="not: token/date_expires">Never</span>
+ </td>
+ <td>
+ <form method="post" tal:attributes="name string:revoke-${token/id}">
+ <input type="hidden" name="token_id"
+ tal:attributes="value token/id" />
+ <input type="submit" name="field.actions.revoke" value="Revoke" />
+ </form>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/services/auth/tests/test_browser.py b/lib/lp/services/auth/tests/test_browser.py
new file mode 100644
index 0000000..6ec0b70
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_browser.py
@@ -0,0 +1,151 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test personal access token views."""
+
+import re
+
+import soupmatchers
+from testtools.matchers import (
+ Equals,
+ Is,
+ MatchesAll,
+ MatchesListwise,
+ MatchesStructure,
+ Not,
+ )
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.services.webapp.interfaces import IPlacelessAuthUtility
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import (
+ login_person,
+ TestCaseWithFactory,
+ )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.views import create_view
+
+
+breadcrumbs_tag = soupmatchers.Tag(
+ "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
+tokens_page_crumb_tag = soupmatchers.Tag(
+ "tokens page breadcrumb", "li", text=re.compile(r"Personal access tokens"))
+token_listing_constants = soupmatchers.HTMLContains(
+ soupmatchers.Within(breadcrumbs_tag, tokens_page_crumb_tag))
+token_listing_tag = soupmatchers.Tag(
+ "tokens table", "table", attrs={"class": "listing"})
+
+
+class TestAccessTokenViewBase:
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.target = self.makeTarget()
+ self.owner = self.target.owner
+ login_person(self.owner)
+
+ def makeView(self, name, **kwargs):
+ # XXX cjwatson 2021-10-19: We need to give the view a
+ # LaunchpadPrincipal rather than just a person, since otherwise bits
+ # of the navigation menu machinery try to use the scope_url
+ # attribute on the principal and fail. This should probably be done
+ # in create_view instead, but that approach needs care to avoid
+ # adding an extra query to tests that might be sensitive to that.
+ principal = getUtility(IPlacelessAuthUtility).getPrincipal(
+ self.owner.accountID)
+ view = create_view(
+ self.target, name, principal=principal, current_request=True,
+ **kwargs)
+ # To test the breadcrumbs we need a correct traversal stack.
+ view.request.traversed_objects = (
+ self.getTraversalStack(self.target) + [view])
+ # The navigation menu machinery needs this to find the view from the
+ # request.
+ view.request._last_obj_traversed = view
+ view.initialize()
+ return view
+
+ def makeTokensAndMatchers(self, count):
+ tokens = [
+ self.factory.makeAccessToken(target=self.target)[1]
+ for _ in range(count)]
+ # There is a row for each token.
+ matchers = []
+ for token in tokens:
+ row_tag = soupmatchers.Tag(
+ "token row", "tr",
+ attrs={"token-id": removeSecurityProxy(token).id})
+ column_tags = [
+ soupmatchers.Tag(
+ "description", "td", text=token.description),
+ soupmatchers.Tag(
+ "scopes", "td",
+ text=", ".join(scope.title for scope in token.scopes)),
+ ]
+ matchers.extend([
+ soupmatchers.Within(row_tag, column_tag)
+ for column_tag in column_tags])
+ return matchers
+
+ def test_empty(self):
+ self.assertThat(
+ self.makeView("+access-tokens")(),
+ MatchesAll(
+ token_listing_constants,
+ soupmatchers.HTMLContains(token_listing_tag)))
+
+ def test_existing_tokens(self):
+ token_matchers = self.makeTokensAndMatchers(10)
+ self.assertThat(
+ self.makeView("+access-tokens")(),
+ MatchesAll(
+ token_listing_constants,
+ soupmatchers.HTMLContains(token_listing_tag, *token_matchers)))
+
+ def test_revoke(self):
+ tokens = [
+ self.factory.makeAccessToken(target=self.target)[1]
+ for _ in range(3)]
+ token_ids = [token.id for token in tokens]
+ access_tokens_url = canonical_url(
+ self.target, view_name="+access-tokens")
+ browser = self.getUserBrowser(access_tokens_url, user=self.owner)
+ for token_id in token_ids:
+ self.assertThat(
+ browser.getForm(name="revoke-%s" % token_id).controls,
+ MatchesListwise([
+ MatchesStructure.byEquality(
+ type="hidden", name="token_id", value=str(token_id)),
+ MatchesStructure.byEquality(
+ type="submit", name="field.actions.revoke",
+ value="Revoke"),
+ ]))
+ browser.getForm(name="revoke-%s" % token_ids[1]).getControl(
+ "Revoke").click()
+ login_person(self.owner)
+ self.assertEqual(access_tokens_url, browser.url)
+ self.assertThat(tokens[0], MatchesStructure(
+ id=Equals(token_ids[0]),
+ date_expires=Is(None),
+ revoked_by=Is(None)))
+ self.assertThat(tokens[1], MatchesStructure(
+ id=Equals(token_ids[1]),
+ date_expires=Not(Is(None)),
+ revoked_by=Equals(self.owner)))
+ self.assertThat(tokens[2], MatchesStructure(
+ id=Equals(token_ids[2]),
+ date_expires=Is(None),
+ revoked_by=Is(None)))
+
+
+class TestAccessTokenViewGitRepository(
+ TestAccessTokenViewBase, TestCaseWithFactory):
+
+ def makeTarget(self):
+ return self.factory.makeGitRepository()
+
+ def getTraversalStack(self, obj):
+ 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):
targets[target_index],
visible_by_user=owners[owner_index]))
+ def test_findByTarget_excludes_expired(self):
+ target = self.factory.makeGitRepository()
+ _, current_token = self.factory.makeAccessToken(target=target)
+ _, expires_soon_token = self.factory.makeAccessToken(
+ target=target,
+ date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
+ _, expired_token = self.factory.makeAccessToken(
+ target=target,
+ date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
+ self.assertContentEqual(
+ [current_token, expires_soon_token],
+ getUtility(IAccessTokenSet).findByTarget(target))
+
+ def test_getByTargetAndID(self):
+ targets = [self.factory.makeGitRepository() for _ in range(3)]
+ tokens = [
+ self.factory.makeAccessToken(target=targets[0])[1],
+ self.factory.makeAccessToken(target=targets[0])[1],
+ self.factory.makeAccessToken(target=targets[1])[1],
+ ]
+ self.assertEqual(
+ tokens[0],
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ targets[0], removeSecurityProxy(tokens[0]).id))
+ self.assertEqual(
+ tokens[1],
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ targets[0], removeSecurityProxy(tokens[1]).id))
+ self.assertIsNone(
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ targets[0], removeSecurityProxy(tokens[2]).id))
+
+ def test_getByTargetAndID_visible_by_user(self):
+ targets = [self.factory.makeGitRepository() for _ in range(3)]
+ owners = [self.factory.makePerson() for _ in range(3)]
+ tokens = [
+ self.factory.makeAccessToken(
+ owner=owners[owner_index], target=targets[target_index])[1]
+ for owner_index, target_index in (
+ (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
+ for owner_index, target_index, expected_tokens in (
+ (0, 0, tokens[:2]),
+ (0, 1, []),
+ (0, 2, []),
+ (1, 0, [tokens[2]]),
+ (1, 1, [tokens[3]]),
+ (1, 2, []),
+ (2, 0, []),
+ (2, 1, [tokens[4]]),
+ (2, 2, []),
+ ):
+ for token in tokens:
+ fetched_token = getUtility(IAccessTokenSet).getByTargetAndID(
+ targets[target_index], removeSecurityProxy(token).id,
+ visible_by_user=owners[owner_index])
+ if token in expected_tokens:
+ self.assertEqual(token, fetched_token)
+ else:
+ self.assertIsNone(fetched_token)
+
+ def test_getByTargetAndID_excludes_expired(self):
+ target = self.factory.makeGitRepository()
+ _, current_token = self.factory.makeAccessToken(target=target)
+ _, expires_soon_token = self.factory.makeAccessToken(
+ target=target,
+ date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
+ _, expired_token = self.factory.makeAccessToken(
+ target=target,
+ date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
+ self.assertEqual(
+ current_token,
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ target, removeSecurityProxy(current_token).id))
+ self.assertEqual(
+ expires_soon_token,
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ target, removeSecurityProxy(expires_soon_token).id))
+ self.assertIsNone(
+ getUtility(IAccessTokenSet).getByTargetAndID(
+ target, removeSecurityProxy(expired_token).id))
+
class TestAccessTokenTargetBase:
diff --git a/lib/lp/services/auth/tests/test_yuitests.py b/lib/lp/services/auth/tests/test_yuitests.py
new file mode 100644
index 0000000..069cc9d
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_yuitests.py
@@ -0,0 +1,23 @@
+# Copyright 2021 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Run YUI.test tests."""
+
+__all__ = []
+
+from lp.testing import (
+ build_yui_unittest_suite,
+ YUIUnitTestCase,
+ )
+from lp.testing.layers import YUITestLayer
+
+
+class AuthYUIUnitTestCase(YUIUnitTestCase):
+
+ layer = YUITestLayer
+ suite_name = "AuthYUIUnitTests"
+
+
+def test_suite():
+ app_testing_path = "lp/services/auth"
+ 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):
token = getUtility(IAccessTokenSet).new(
secret, owner, description, target, scopes,
date_expires=date_expires)
+ IStore(token).flush()
return secret, token
def makeCVE(self, sequence, description=None,