diff --git a/data/database.py b/data/database.py index 3e98b83be..a659e7d50 100644 --- a/data/database.py +++ b/data/database.py @@ -113,6 +113,7 @@ class TeamMemberInvite(BaseModel): user = ForeignKeyField(User, index=True, null=True) email = CharField(null=True) team = ForeignKeyField(Team, index=True) + inviter = ForeignKeyField(User, related_name='inviter') invite_token = CharField(default=uuid_generator) diff --git a/data/model/legacy.py b/data/model/legacy.py index 64bf8d4f8..52248958c 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -71,6 +71,10 @@ class TooManyUsersException(DataModelException): pass +class UserAlreadyInTeam(DataModelException): + pass + + def is_create_user_allowed(): return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT'] @@ -294,7 +298,7 @@ def remove_team(org_name, team_name, removed_by_username): team.delete_instance(recursive=True, delete_nullable=True) -def add_or_invite_to_team(team, user=None, email=None, adder=None): +def add_or_invite_to_team(inviter, team, user=None, email=None): # If the user is a member of the organization, then we simply add the # user directly to the team. Otherwise, an invite is created for the user/email. # We return None if the user was directly added and the invite object if the user was invited. @@ -326,15 +330,16 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None): add_user_to_team(user, team) return None - return TeamMemberInvite.create(user=user, email=email if not user else None, team=team) + return TeamMemberInvite.create(user=user, email=email if not user else None, team=team, + inviter=inviter) def add_user_to_team(user, team): try: return TeamMember.create(user=user, team=team) except Exception: - raise DataModelException('User \'%s\' is already a member of team \'%s\'' % - (user.username, team.name)) + raise UserAlreadyInTeam('User \'%s\' is already a member of team \'%s\'' % + (user.username, team.name)) def remove_user_from_team(org_name, team_name, username, removed_by_username): @@ -1766,6 +1771,32 @@ def delete_notifications_by_kind(target, kind_name): Notification.delete().where(Notification.target == target, Notification.kind == kind_ref).execute() +def delete_matching_notifications(target, kind_name, **kwargs): + kind_ref = NotificationKind.get(name=kind_name) + + # Load all notifications for the user with the given kind. + notifications = list(Notification.select().where( + Notification.target == target, + Notification.kind == kind_ref)) + + # For each, match the metadata to the specified values. + for notification in notifications: + matches = True + try: + metadata = json.loads(notification.metadata_json) + except: + continue + + for (key, value) in kwargs.iteritems(): + if not key in metadata or metadata[key] != value: + matches = False + break + + if not matches: + continue + + notification.delete_instance() + def get_active_users(): return User.select().where(User.organization == False, User.robot == False) @@ -1821,3 +1852,51 @@ def confirm_email_authorization_for_repo(code): found.save() return found + + +def lookup_team_invites(user): + return TeamMemberInvite.select().where(TeamMemberInvite.user == user) + +def lookup_team_invite(code, user): + # Lookup the invite code. + try: + found = TeamMemberInvite.get(TeamMemberInvite.invite_token == code) + except TeamMemberInvite.DoesNotExist: + raise DataModelException('Invalid confirmation code.') + + # Verify the code applies to the current user. + if found.user: + if found.user != user: + raise DataModelException('Invalid confirmation code.') + else: + if found.email != user.email: + raise DataModelException('Invalid confirmation code.') + + return found + + +def delete_team_invite(code, user): + found = lookup_team_invite(code, user) + + team = found.team + inviter = found.inviter + + found.delete_instance() + + return (team, inviter) + +def confirm_team_invite(code, user): + found = lookup_team_invite(code, user) + + # Add the user to the team. + try: + add_user_to_team(user, found.team) + except UserAlreadyInTeam: + # Ignore. + pass + + # Delete the invite and return the team. + team = found.team + inviter = found.inviter + found.delete_instance() + return (team, inviter) diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 3c0751a56..37efd44f2 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -2,7 +2,7 @@ from flask import request from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, log_action, Unauthorized, NotFound, internal_only, require_scope, - query_param, truthy_bool, parse_args) + query_param, truthy_bool, parse_args, require_user_admin) from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission from auth.auth_context import get_authenticated_user from auth import scopes @@ -10,8 +10,8 @@ from data import model from util.useremails import send_org_invite_email from util.gravatar import compute_hash -def add_or_invite_to_team(team, user=None, email=None, adder=None): - invite = model.add_or_invite_to_team(team, user, email, adder) +def add_or_invite_to_team(inviter, team, user=None, email=None): + invite = model.add_or_invite_to_team(inviter, team, user, email) if not invite: # User was added to the team directly. return @@ -20,13 +20,13 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None): if user: model.create_notification('org_team_invite', user, metadata = { 'code': invite.invite_token, - 'adder': adder, + 'inviter': inviter.username, 'org': orgname, 'team': team.name }) send_org_invite_email(user.username if user else email, user.email if user else email, - orgname, team.name, adder, invite.invite_token) + orgname, team.name, inviter.username, invite.invite_token) return invite def team_view(orgname, team): @@ -204,11 +204,8 @@ class TeamMember(ApiResource): raise request_error(message='Unknown user') # Add or invite the user to the team. - adder = None - if get_authenticated_user(): - adder = get_authenticated_user().username - - invite = add_or_invite_to_team(team, user=user, adder=adder) + inviter = get_authenticated_user() + invite = add_or_invite_to_team(inviter, team, user=user) if not invite: log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) return member_view(user, invited=False) @@ -232,3 +229,52 @@ class TeamMember(ApiResource): return 'Deleted', 204 raise Unauthorized() + + +@resource('/v1/teaminvite/<code>') +@internal_only +class TeamMemberInvite(ApiResource): + """ Resource for managing invites to jon a team. """ + @require_user_admin + @nickname('acceptOrganizationTeamInvite') + def put(self, code): + """ Accepts an invite to join a team in an organization. """ + # Accept the invite for the current user. + try: + (team, inviter) = model.confirm_team_invite(code, get_authenticated_user()) + except model.DataModelException: + raise NotFound() + + model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code) + + orgname = team.organization.username + log_action('org_team_member_invite_accepted', orgname, { + 'member': get_authenticated_user().username, + 'team': team.name, + 'inviter': inviter.username + }) + + return { + 'org': orgname, + 'team': team.name + } + + @nickname('declineOrganizationTeamInvite') + @require_user_admin + def delete(self, code): + """ Delete an existing member of a team. """ + try: + (team, inviter) = model.delete_team_invite(code, get_authenticated_user()) + except model.DataModelException: + raise NotFound() + + model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code) + + orgname = team.organization.username + log_action('org_team_member_invite_declined', orgname, { + 'member': get_authenticated_user().username, + 'team': team.name, + 'inviter': inviter.username + }) + + return 'Deleted', 204 diff --git a/endpoints/web.py b/endpoints/web.py index 19f9bb7f1..b1e69bc5e 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -32,8 +32,8 @@ STATUS_TAGS = app.config['STATUS_TAGS'] @web.route('/', methods=['GET'], defaults={'path': ''}) @web.route('/organization/<path:path>', methods=['GET']) @no_cache -def index(path): - return render_page_template('index.html') +def index(path, **kwargs): + return render_page_template('index.html', **kwargs) @web.route('/500', methods=['GET']) @@ -101,7 +101,7 @@ def superuser(): @web.route('/signin/') @no_cache -def signin(): +def signin(redirect=None): return index('') @@ -123,6 +123,13 @@ def new(): return index('') +@web.route('/confirminvite') +@no_cache +def confirm_invite(): + code = request.values['code'] + return index('', code=code) + + @web.route('/repository/', defaults={'path': ''}) @web.route('/repository/<path:path>', methods=['GET']) @no_cache diff --git a/initdb.py b/initdb.py index 21485d5a4..860b4e135 100644 --- a/initdb.py +++ b/initdb.py @@ -214,6 +214,8 @@ def initialize_database(): LogEntryKind.create(name='org_delete_team') LogEntryKind.create(name='org_invite_team_member') LogEntryKind.create(name='org_add_team_member') + LogEntryKind.create(name='org_team_member_invite_accepted') + LogEntryKind.create(name='org_team_member_invite_declined') LogEntryKind.create(name='org_remove_team_member') LogEntryKind.create(name='org_set_team_description') LogEntryKind.create(name='org_set_team_role') diff --git a/static/js/app.js b/static/js/app.js index 74a7b3454..e17e36e6f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -535,7 +535,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading stringBuilderService.buildString = function(value_or_func, metadata) { var fieldIcons = { - 'adder': 'user', + 'inviter': 'user', 'username': 'user', 'activating_username': 'user', 'delegate_user': 'user', @@ -1115,8 +1115,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return externalNotificationData; }]); - $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', - function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config) { + $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location', + function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) { var notificationService = { 'user': null, 'notifications': [], @@ -1135,15 +1135,24 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading }, 'org_team_invite': { 'level': 'primary', - 'message': '{adder} is inviting you to join team {team} under organization {org}', + 'message': '{inviter} is inviting you to join team {team} under organization {org}', 'actions': [ { 'title': 'Join team', 'kind': 'primary', 'handler': function(notification) { + window.location = '/confirminvite?code=' + notification.metadata['code']; } }, - {'title': 'Decline', 'kind': 'default'} + { + 'title': 'Decline', + 'kind': 'default', + 'handler': function(notification) { + ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() { + notificationService.update(); + }); + } + } ] }, 'password_required': { @@ -1725,7 +1734,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data', templateUrl: '/static/partials/security.html'}). - when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html'}). + when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html', controller: SignInCtrl, reloadOnSearch: false}). when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile', templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}). when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations', @@ -1746,6 +1755,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). + when('/confirminvite', {title: 'Confirm Team Invite', templateUrl: '/static/partials/confirm-team-invite.html', controller: ConfirmInviteCtrl, reloadOnSearch: false}). + when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl, pageClass: 'landing-page'}). otherwise({redirectTo: '/'}); @@ -2244,6 +2255,10 @@ quayApp.directive('signinForm', function () { 'signedIn': '&signedIn' }, controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) { + var getRedirectUrl = function() { + return $scope.redirectUrl; + }; + $scope.showGithub = function() { if (!Features.GITHUB_LOGIN) { return; } @@ -2255,7 +2270,7 @@ quayApp.directive('signinForm', function () { } // Save the redirect URL in a cookie so that we can redirect back after GitHub returns to us. - var redirectURL = $scope.redirectUrl || window.location.toString(); + var redirectURL = getRedirectUrl() || window.location.toString(); CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); // Needed to ensure that UI work done by the started callback is finished before the location @@ -2283,17 +2298,19 @@ quayApp.directive('signinForm', function () { if ($scope.signedIn != null) { $scope.signedIn(); } - + + // Load the newly created user. UserService.load(); // Redirect to the specified page or the landing page // Note: The timeout of 500ms is needed to ensure dialogs containing sign in // forms get removed before the location changes. $timeout(function() { - if ($scope.redirectUrl == $location.path()) { - return; - } - $location.path($scope.redirectUrl ? $scope.redirectUrl : '/'); + var redirectUrl = getRedirectUrl(); + if (redirectUrl == $location.path()) { + return; + } + window.location = (redirectUrl ? redirectUrl : '/'); }, 500); }, function(result) { $scope.needsEmailVerification = result.data.needsEmailVerification; @@ -2629,8 +2646,12 @@ quayApp.directive('logsView', function () { 'org_create_team': 'Create team: {team}', 'org_delete_team': 'Delete team: {team}', 'org_add_team_member': 'Add member {member} to team {team}', - 'org_invite_team_member': 'Invite user {member} to team {team}', 'org_remove_team_member': 'Remove member {member} from team {team}', + 'org_invite_team_member': 'Invite user {member} to team {team}', + + 'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, accepted to join team {team}', + 'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}', + 'org_set_team_description': 'Change description of team {team}: {description}', 'org_set_team_role': 'Change permission of team {team} to {role}', 'create_prototype_permission': function(metadata) { @@ -2711,6 +2732,8 @@ quayApp.directive('logsView', function () { 'org_add_team_member': 'Add team member', 'org_invite_team_member': 'Invite team member', 'org_remove_team_member': 'Remove team member', + 'org_team_member_invite_accepted': 'Team invite accepted', + 'org_team_member_invite_declined': 'Team invite declined', 'org_set_team_description': 'Change team description', 'org_set_team_role': 'Change team permission', 'create_prototype_permission': 'Create default permission', @@ -5346,7 +5369,7 @@ quayApp.directive('dockerfileBuildForm', function () { var data = { 'mimeType': mimeType }; - + var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) { conductUpload(file, resp.url, resp.file_id, mimeType); }, function() { diff --git a/static/js/controllers.js b/static/js/controllers.js index 56689e80a..82ed5c684 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -20,6 +20,17 @@ $.fn.clipboardCopy = function() { }); }; +function SignInCtrl($scope, $location) { + var redirect = $location.search()['redirect']; + if (redirect && redirect.indexOf('/') < 0) { + delete $location.search()['redirect']; + $scope.redirectUrl = '/' + redirect; + return; + } + + $scope.redirectUrl = '/'; +} + function GuideCtrl() { } @@ -2855,3 +2866,28 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) { function TourCtrl($scope, $location) { $scope.kind = $location.path().substring('/tour/'.length); } + +function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) { + // Monitor any user changes and place the current user into the scope. + $scope.loading = false; + + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous && !$scope.loading) { + $scope.loading = true; + + var params = { + 'code': $location.search()['code'] + }; + + ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) { + NotificationService.update(); + $location.path('/organization/' + resp.org + '/teams/' + resp.team); + }, function() { + $scope.loading = false; + $scope.invalid = true; + }); + } + }); + + $scope.redirectUrl = 'confirminvite?code=' + $location.search()['code']; +} \ No newline at end of file diff --git a/static/partials/confirm-team-invite.html b/static/partials/confirm-team-invite.html new file mode 100644 index 000000000..625e9e262 --- /dev/null +++ b/static/partials/confirm-team-invite.html @@ -0,0 +1,13 @@ +<div class="confirm-team-invite"> + <div class="container signin-container"> + <div class="row"> + <div class="col-sm-6 col-sm-offset-3"> + <div class="user-setup" ng-show="user.anonymous" redirect-url="redirectUrl"></div> + <div class="quay-spinner" ng-show="!user.anonymous && loading"></div> + <div class="alert alert-danger" ng-show="!user.anonymous && invalid"> + Invalid confirmation code + </div> + </div> + </div> + </div> +</div> diff --git a/static/partials/signin.html b/static/partials/signin.html index 4aac6cb7e..2a1a9563d 100644 --- a/static/partials/signin.html +++ b/static/partials/signin.html @@ -1,7 +1,7 @@ <div class="container signin-container"> <div class="row"> <div class="col-sm-6 col-sm-offset-3"> - <div class="user-setup" redirect-url="'/'"></div> + <div class="user-setup" redirect-url="redirectUrl"></div> </div> </div> </div> diff --git a/test/test_api_security.py b/test/test_api_security.py index 5b3e5612d..364432d4b 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -8,7 +8,7 @@ from app import app from initdb import setup_database_for_testing, finished_database_for_testing from endpoints.api import api_bp, api -from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam +from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList @@ -3424,6 +3424,36 @@ class TestSuperUserLogs(ApiTestCase): self._run_test('GET', 200, 'devtable', None) +class TestTeamMemberInvite(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(TeamMemberInvite, code='foobarbaz') + + def test_put_anonymous(self): + self._run_test('PUT', 401, None, None) + + def test_put_freshuser(self): + self._run_test('PUT', 404, 'freshuser', None) + + def test_put_reader(self): + self._run_test('PUT', 404, 'reader', None) + + def test_put_devtable(self): + self._run_test('PUT', 404, 'devtable', None) + + def test_delete_anonymous(self): + self._run_test('DELETE', 401, None, None) + + def test_delete_freshuser(self): + self._run_test('DELETE', 404, 'freshuser', None) + + def test_delete_reader(self): + self._run_test('DELETE', 404, 'reader', None) + + def test_delete_devtable(self): + self._run_test('DELETE', 404, 'devtable', None) + + class TestSuperUserList(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) @@ -3442,7 +3472,6 @@ class TestSuperUserList(ApiTestCase): self._run_test('GET', 200, 'devtable', None) - class TestSuperUserManagement(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index c91005c5c..99086e45b 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -11,7 +11,7 @@ from app import app from initdb import setup_database_for_testing, finished_database_for_testing from data import model, database -from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam +from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam from endpoints.api.tag import RepositoryTagImages, RepositoryTag from endpoints.api.search import FindRepositories, EntitySearch from endpoints.api.image import RepositoryImage, RepositoryImageList @@ -734,16 +734,50 @@ class TestGetOrganizationTeamMembers(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert READ_ACCESS_USER in json['members'] + self.assertEquals(READ_ACCESS_USER, json['members'][1]['name']) class TestUpdateOrganizationTeamMember(ApiTestCase): - def test_addmember(self): + def assertInTeam(self, data, membername): + for memberData in data['members']: + if memberData['name'] == membername: + return + + self.fail(membername + ' not found in team: ' + json.dumps(data)) + + def test_addmember_alreadyteammember(self): self.login(ADMIN_ACCESS_USER) + membername = READ_ACCESS_USER + self.putResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='readers', + membername=membername), + expected_code=400) + + + def test_addmember_orgmember(self): + self.login(ADMIN_ACCESS_USER) + + membername = READ_ACCESS_USER + self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + # Verify the user was added to the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='owners')) + + self.assertInTeam(json, membername) + + + def test_addmember_robot(self): + self.login(ADMIN_ACCESS_USER) + + membername = ORGANIZATION + '+coolrobot' self.putJsonResponse(TeamMember, params=dict(orgname=ORGANIZATION, teamname='readers', - membername=NO_ACCESS_USER)) + membername=membername)) # Verify the user was added to the team. @@ -751,7 +785,152 @@ class TestUpdateOrganizationTeamMember(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert NO_ACCESS_USER in json['members'] + self.assertInTeam(json, membername) + + + def test_addmember_invalidrobot(self): + self.login(ADMIN_ACCESS_USER) + + membername = 'freshuser+anotherrobot' + self.putResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='readers', + membername=membername), + expected_code=400) + + + def test_addmember_nonorgmember(self): + self.login(ADMIN_ACCESS_USER) + + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + + self.assertEquals(True, response['invited']) + + # Make sure the user is not (yet) part of the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='readers')) + + for member in json['members']: + self.assertNotEqual(membername, member['name']) + + +class TestAcceptTeamMemberInvite(ApiTestCase): + def assertInTeam(self, data, membername): + for memberData in data['members']: + if memberData['name'] == membername: + return + + self.fail(membername + ' not found in team: ' + json.dumps(data)) + + def test_accept_wronguser(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Try to accept the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.putResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + def test_accept(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Login as the user. + self.login(membername) + + # Accept the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.putJsonResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token)) + + # Verify the user is now on the team. + json = self.getJsonResponse(TeamMemberList, + params=dict(orgname=ORGANIZATION, + teamname='owners')) + + self.assertInTeam(json, membername) + + # Verify the accept now fails. + self.putResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + +class TestDeclineTeamMemberInvite(ApiTestCase): + def test_decline_wronguser(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Try to decline the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) + + + def test_decline(self): + self.login(ADMIN_ACCESS_USER) + + # Create the invite. + membername = NO_ACCESS_USER + response = self.putJsonResponse(TeamMember, + params=dict(orgname=ORGANIZATION, teamname='owners', + membername=membername)) + + self.assertEquals(True, response['invited']) + + # Login as the user. + self.login(membername) + + # Decline the invite. + user = model.get_user(membername) + invites = list(model.lookup_team_invites(user)) + self.assertEquals(1, len(invites)) + + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token)) + + # Make sure the invite was deleted. + self.deleteResponse(TeamMemberInvite, + params=dict(code=invites[0].invite_token), + expected_code=404) class TestDeleteOrganizationTeamMember(ApiTestCase): @@ -768,7 +947,7 @@ class TestDeleteOrganizationTeamMember(ApiTestCase): params=dict(orgname=ORGANIZATION, teamname='readers')) - assert not READ_ACCESS_USER in json['members'] + assert len(json['members']) == 1 class TestCreateRepo(ApiTestCase): @@ -2064,7 +2243,7 @@ class TestSuperUserManagement(ApiTestCase): json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) self.assertEquals('freshuser', json['username']) - self.assertEquals('no@thanks.com', json['email']) + self.assertEquals('jschorr+test@devtable.com', json['email']) self.assertEquals(False, json['super_user']) def test_delete_user(self): @@ -2087,7 +2266,7 @@ class TestSuperUserManagement(ApiTestCase): # Verify the user exists. json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser')) self.assertEquals('freshuser', json['username']) - self.assertEquals('no@thanks.com', json['email']) + self.assertEquals('jschorr+test@devtable.com', json['email']) # Update the user. self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com')) diff --git a/util/useremails.py b/util/useremails.py index 33307623c..7e727cb40 100644 --- a/util/useremails.py +++ b/util/useremails.py @@ -67,7 +67,7 @@ To confirm this email address, please click the following link:<br> INVITE_TO_ORG_TEAM_MESSAGE = """ Hi {0},<br> -{1} has invited you to join the team {2} under organization {3} on <a href="{4}">{5}</a>. +{1} has invited you to join the team <b>{2}</b> under organization <b>{3}</b> on <a href="{4}">{5}</a>. <br><br> To join the team, please click the following link:<br> <a href="{4}/confirminvite?code={6}">{4}/confirminvite?code={6}</a>